[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
/** Renderer-side Catty tool approval idle timeout (5 minutes). */
export const CATTY_APPROVAL_TIMEOUT_MS = 5 * 60 * 1000;
/**
* Hard ceiling from approval creation. Review activity re-arms the idle timer
* but never past this bound (3× idle for the default 5m → 15m).
*/
export const CATTY_APPROVAL_HARD_DEADLINE_MS = 15 * 60 * 1000;
/** Absolute upper bound for any Catty approval hard deadline (30 minutes). */
export const CATTY_APPROVAL_HARD_DEADLINE_MAX_MS = 30 * 60 * 1000;
/**
* MCP / external SDK approval timeout aligned with Codex MCP limits (~110s).
* Kept separate from Catty because external agents block on main-process IPC.
*/
export const MCP_APPROVAL_TIMEOUT_MS = 110 * 1000;
/**
* Resolve idle vs hard deadline for a Catty approval request.
* hardDeadlineMs is always >= idleMs and capped by the 30m global max when
* the 3× multiplier would exceed it (unless idle itself is larger).
*/
export function resolveCattyApprovalDeadlines(timeoutMs: number = CATTY_APPROVAL_TIMEOUT_MS): {
idleMs: number;
hardDeadlineMs: number;
} {
const idleMs = Math.max(0, Number.isFinite(timeoutMs) ? timeoutMs : CATTY_APPROVAL_TIMEOUT_MS);
const scaled = idleMs * 3;
const hardDeadlineMs = Math.max(
idleMs,
Math.min(scaled, CATTY_APPROVAL_HARD_DEADLINE_MAX_MS),
);
return { idleMs, hardDeadlineMs };
}

View File

@@ -0,0 +1,303 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { setTimeout as delay } from 'node:timers/promises';
import {
cancelApprovalTimeout,
clearAllPendingApprovals,
onApprovalCleared,
requestApproval,
resolveApproval,
} from './approvalGate';
import { resolveCattyApprovalDeadlines } from './approvalConstants';
function stubNow(startMs: number): { advance: (deltaMs: number) => void; restore: () => void } {
const realNow = Date.now;
let now = startMs;
Date.now = () => now;
return {
advance: (deltaMs: number) => {
now += deltaMs;
},
restore: () => {
Date.now = realNow;
},
};
}
test('resolveCattyApprovalDeadlines keeps hard deadline >= idle and 3x by default', () => {
assert.deepEqual(resolveCattyApprovalDeadlines(100), { idleMs: 100, hardDeadlineMs: 300 });
assert.deepEqual(resolveCattyApprovalDeadlines(5 * 60 * 1000), {
idleMs: 5 * 60 * 1000,
hardDeadlineMs: 15 * 60 * 1000,
});
// Cap at 30m when 3× would exceed, unless idle itself is larger.
assert.deepEqual(resolveCattyApprovalDeadlines(20 * 60 * 1000), {
idleMs: 20 * 60 * 1000,
hardDeadlineMs: 30 * 60 * 1000,
});
assert.deepEqual(resolveCattyApprovalDeadlines(40 * 60 * 1000), {
idleMs: 40 * 60 * 1000,
hardDeadlineMs: 40 * 60 * 1000,
});
});
test('cancelApprovalTimeout re-arms a fresh idle window, not the original absolute remainder', async () => {
clearAllPendingApprovals();
const cleared: string[] = [];
const unsub = onApprovalCleared((ids) => {
cleared.push(...ids);
});
const clock = stubNow(1_000_000);
try {
const toolCallId = `timeout-idle-rearm-${Date.now()}`;
// idle 100ms, hard 300ms
const approvalPromise = requestApproval(
toolCallId,
'terminal_execute',
{ sessionId: 's1', command: 'echo hi' },
'chat-1',
100,
);
// Review near end of first idle window — previously this left ~30ms absolute
// remainder and denied while the user was still deciding.
clock.advance(70);
cancelApprovalTimeout(toolCallId);
// Fresh idle (100ms) should keep the approval pending well past the old
// absolute mark at t=100.
await delay(50);
assert.equal(cleared.includes(toolCallId), false, 'must stay pending through re-armed idle');
// Still before hard deadline (300): jump clock so remaining hard is short.
clock.advance(220); // now = start+290; remaining hard = 10ms; re-arm idle capped to 10
cancelApprovalTimeout(toolCallId);
const outcome = await Promise.race([
approvalPromise.then((approved) => ({ approved })),
delay(120).then(() => ({ approved: 'timeout-wait' as const })),
]);
assert.deepEqual(outcome, { approved: false });
assert.ok(cleared.includes(toolCallId));
} finally {
clock.restore();
unsub();
clearAllPendingApprovals();
}
});
test('cancelApprovalTimeout survives past the idle deadline while reviewing', async () => {
clearAllPendingApprovals();
const cleared: string[] = [];
const unsub = onApprovalCleared((ids) => {
cleared.push(...ids);
});
const clock = stubNow(3_000_000);
try {
const toolCallId = `timeout-past-idle-${Date.now()}`;
const idleMs = 80;
const approvalPromise = requestApproval(
toolCallId,
'terminal_execute',
{ sessionId: 's1', command: 'echo hi' },
'chat-1',
idleMs,
);
// Review near end of first idle — re-arms a full idleMs from now (hard = 3x).
clock.advance(idleMs - 20);
cancelApprovalTimeout(toolCallId);
// Wall-clock past the original idle mark; re-armed idle still has ~idleMs left.
await delay(40);
assert.equal(cleared.includes(toolCallId), false, 'active review must outlive original idle');
resolveApproval(toolCallId, true);
assert.equal(await approvalPromise, true);
} finally {
clock.restore();
unsub();
clearAllPendingApprovals();
}
});
test('cancelApprovalTimeout still allows explicit approve before hard Catty deadline', async () => {
clearAllPendingApprovals();
const clock = stubNow(2_000_000);
try {
const toolCallId = `timeout-approve-${Date.now()}`;
const approvalPromise = requestApproval(
toolCallId,
'sftp_write',
{ path: '/tmp/x' },
'chat-1',
200,
);
clock.advance(150);
cancelApprovalTimeout(toolCallId);
resolveApproval(toolCallId, true);
assert.equal(await approvalPromise, true);
} finally {
clock.restore();
clearAllPendingApprovals();
}
});
test('cancelApprovalTimeout rejects expired Catty approvals synchronously after hard deadline', async () => {
clearAllPendingApprovals();
const cleared: string[] = [];
const unsub = onApprovalCleared((ids) => {
cleared.push(...ids);
});
const clock = stubNow(4_000_000);
try {
const toolCallId = `timeout-hard-sync-${Date.now()}`;
// idle 50ms → hard 150ms
const approvalPromise = requestApproval(
toolCallId,
'terminal_execute',
{ sessionId: 's1', command: 'echo hi' },
'chat-1',
50,
);
clock.advance(160); // past hard deadline
cancelApprovalTimeout(toolCallId);
// Must already be denied — no setTimeout(0) race window for approve.
assert.ok(cleared.includes(toolCallId), 'must clear synchronously when hard deadline elapsed');
assert.equal(await approvalPromise, false);
// Late approve after hard-deadline deny must not resurrect the request.
resolveApproval(toolCallId, true);
assert.equal(await approvalPromise, false);
} finally {
clock.restore();
unsub();
clearAllPendingApprovals();
}
});
test('repeated cancelApprovalTimeout re-arms idle on each review interaction', async () => {
clearAllPendingApprovals();
const cleared: string[] = [];
const unsub = onApprovalCleared((ids) => {
cleared.push(...ids);
});
const clock = stubNow(5_000_000);
try {
const toolCallId = `timeout-multi-rearm-${Date.now()}`;
const idleMs = 100;
const approvalPromise = requestApproval(
toolCallId,
'terminal_execute',
{ sessionId: 's1', command: 'echo hi' },
'chat-1',
idleMs,
);
// Simulate successive review events (focus, scroll, key) — each re-arms.
clock.advance(60);
cancelApprovalTimeout(toolCallId);
clock.advance(60);
cancelApprovalTimeout(toolCallId);
clock.advance(60);
cancelApprovalTimeout(toolCallId);
// Still before hard deadline (300ms); original idle marks have long passed.
await delay(20);
assert.equal(cleared.includes(toolCallId), false, 'each re-arm must keep approval pending');
resolveApproval(toolCallId, true);
assert.equal(await approvalPromise, true);
} finally {
clock.restore();
unsub();
clearAllPendingApprovals();
}
});
test('idle approval timeout still auto-denies when the user never reviews', async () => {
clearAllPendingApprovals();
const cleared: string[] = [];
const unsub = onApprovalCleared((ids) => {
cleared.push(...ids);
});
const toolCallId = `timeout-fire-${Date.now()}`;
const approved = await requestApproval(
toolCallId,
'terminal_execute',
{ sessionId: 's1', command: 'echo hi' },
'chat-1',
30,
);
assert.equal(approved, false);
assert.ok(cleared.includes(toolCallId));
unsub();
clearAllPendingApprovals();
});
test('cancelApprovalTimeout asks main to drop Codex App Server interaction timers', () => {
clearAllPendingApprovals();
const calls: string[] = [];
const previous = (globalThis as { window?: unknown }).window;
(globalThis as { window?: unknown }).window = {
netcatty: {
cancelCodexAppServerInteractionTimeout: async (id: string) => {
calls.push(id);
return { ok: true, cancelled: true };
},
},
};
try {
const toolCallId = `codex_interaction_1_${Date.now()}`;
cancelApprovalTimeout(toolCallId);
assert.deepEqual(calls, [toolCallId]);
} finally {
(globalThis as { window?: unknown }).window = previous;
clearAllPendingApprovals();
}
});
test('requestApproval joins an existing waiter for the same toolCallId', async () => {
clearAllPendingApprovals();
const toolCallId = `dup-approval-${Date.now()}`;
const first = requestApproval(toolCallId, 'sftp_write', { path: '/tmp/a' }, 'chat-1', 60_000);
const second = requestApproval(toolCallId, 'sftp_write', { path: '/tmp/a' }, 'chat-1', 60_000);
resolveApproval(toolCallId, { approved: true, scope: 'once' });
assert.deepEqual(await Promise.all([first, second]), [true, true]);
clearAllPendingApprovals();
});
test('resolveApproval skips MCP IPC when the pending entry is already gone', () => {
clearAllPendingApprovals();
const calls: Array<{ id: string; approved: boolean }> = [];
const previous = (globalThis as { window?: unknown }).window;
(globalThis as { window?: unknown }).window = {
netcatty: {
respondMcpApproval: async (id: string, approved: boolean) => {
calls.push({ id, approved });
return { ok: true };
},
},
};
try {
resolveApproval('mcp_approval_stale', true);
assert.deepEqual(calls, []);
} finally {
(globalThis as { window?: unknown }).window = previous;
clearAllPendingApprovals();
}
});

View File

@@ -0,0 +1,528 @@
/**
* approvalGate — Promise-based approval system for tool execution.
*
* Catty write tools are gated by `streamText({ toolApproval })` (see cattyToolApproval.ts).
* MCP/external agents use main-process approval via `setupMcpApprovalBridge()`.
* `requestApproval()` is the shared renderer Promise used by both paths.
* a Promise that resolves when the user approves/rejects from the UI, or after
* a timeout (default 5 minutes) to prevent indefinite hangs.
*
* Also supports MCP/SDK-agent tool calls from the Electron main process:
* the main process sends an IPC approval request, and we route it
* through the same listener/UI system. MCP approvals are stored in
* the same pendingApprovals map so they survive ChatMessageList
* unmount/remount cycles via replayPendingApprovals().
*
* Approvals are scoped by optional chatSessionId to prevent cross-session
* interference when stopping or cancelling sessions.
*/
import {
CATTY_APPROVAL_TIMEOUT_MS,
resolveCattyApprovalDeadlines,
} from './approvalConstants';
import { localStorageAdapter } from '../../persistence/localStorageAdapter';
import { STORAGE_KEY_AI_PERMISSION_GRANTS } from '../../config/storageKeys';
import { globalTraceStore } from '../harness/traceStore';
import {
getActivePermissionGrants,
matchPermissionGrant,
resolveCapabilityId,
sanitizePermissionGrants,
setActivePermissionGrants,
type PermissionGrantRule,
} from '../harness/permissionGrants';
export interface ApprovalRequest {
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
/** Optional chat session scope — used to clear only relevant approvals on stop */
chatSessionId?: string;
capabilityId?: string;
source?: 'catty' | 'mcp' | 'codex-app-server';
approvalType?: 'command' | 'file-change' | 'permissions';
itemId?: string;
allowSession?: boolean;
}
export interface ResolveApprovalOptions {
approved: boolean;
persistGrant?: PermissionGrantRule;
persistGrants?: PermissionGrantRule[];
scope?: 'once' | 'session';
cancelled?: boolean;
}
export type ApprovalResolution = {
approved: boolean;
scope: 'once' | 'session';
cancelled: boolean;
};
export type GrantPersister = (rule: PermissionGrantRule) => void;
let grantPersister: GrantPersister | null = null;
const grantPersisterStack: GrantPersister[] = [];
function refreshPermissionGrantsFromStorage(): void {
if (typeof window === 'undefined') return;
setActivePermissionGrants(
sanitizePermissionGrants(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_PERMISSION_GRANTS)),
);
}
export function setGrantPersister(persister: GrantPersister | null): void {
grantPersisterStack.length = 0;
if (persister) {
grantPersisterStack.push(persister);
}
grantPersister = persister;
}
/** Register a grant persister; supports multiple mounted AI panels via a stack. */
export function registerGrantPersister(persister: GrantPersister): () => void {
grantPersisterStack.push(persister);
grantPersister = persister;
return () => {
const idx = grantPersisterStack.lastIndexOf(persister);
if (idx >= 0) {
grantPersisterStack.splice(idx, 1);
}
grantPersister = grantPersisterStack[grantPersisterStack.length - 1] ?? null;
};
}
// Pending approval entries keyed by toolCallId.
// SDK approvals have a real `resolve` callback; MCP approvals use a no-op
// (the real resolution goes via IPC in resolveApproval).
const pendingApprovals = new Map<string, {
resolve: (resolution: ApprovalResolution) => void;
request: ApprovalRequest;
/** Clears the auto-deny timer without resolving the approval. */
cancelTimeout?: () => void;
}>();
// Subscribers for approval request events (UI listens here)
type ApprovalRequestListener = (request: ApprovalRequest) => void;
const listeners = new Set<ApprovalRequestListener>();
// Subscribers for approval cleared/removed events (UI listens to clean up cards)
type ApprovalClearedListener = (toolCallIds: string[]) => void;
const clearedListeners = new Set<ApprovalClearedListener>();
let approvalEventCounter = 0;
function nextApprovalEventId(prefix: string): string {
approvalEventCounter += 1;
return `${prefix}-${Date.now()}-${approvalEventCounter}`;
}
function emitApprovalEvent(
type: 'approval_requested' | 'approval_resolved',
request: ApprovalRequest,
extra?: { outcome?: 'approved' | 'denied' | 'timeout'; persistedGrantId?: string },
): void {
const sessionId = request.chatSessionId ?? 'global';
const base = {
sessionId,
chatSessionId: request.chatSessionId,
backend: request.source === 'codex-app-server' ? 'external-sdk' as const : 'catty' as const,
timestamp: Date.now(),
toolCallId: request.toolCallId,
toolName: request.toolName,
};
if (type === 'approval_requested') {
globalTraceStore.append({
...base,
id: nextApprovalEventId('approval-requested'),
type: 'approval_requested',
args: request.args,
});
return;
}
globalTraceStore.append({
...base,
id: nextApprovalEventId('approval-resolved'),
type: 'approval_resolved',
outcome: extra?.outcome ?? 'denied',
persistedGrantId: extra?.persistedGrantId,
});
}
function isGrantedByRules(request: ApprovalRequest): boolean {
refreshPermissionGrantsFromStorage();
const capabilityId = request.capabilityId ?? resolveCapabilityId(request.toolName);
return matchPermissionGrant(getActivePermissionGrants(), {
capabilityId,
chatSessionId: request.chatSessionId,
sessionId: typeof request.args.sessionId === 'string' ? request.args.sessionId : undefined,
args: request.args,
}) !== null;
}
/**
* Called from a tool's `execute` function when it needs user approval.
* Returns a Promise<boolean> that resolves to `true` (approved) or `false` (denied).
* The UI is notified via the listener system to render approval buttons.
*
* Idle auto-deny uses `timeoutMs` (default 5 minutes). Review activity re-arms
* a fresh idle window from *now*, but never past a hard deadline from creation
* (default 3× idle, capped at 30 minutes) so late review is not cut off at the
* original idle mark while still staying bounded.
*/
export function requestApproval(
toolCallId: string,
toolName: string,
args: Record<string, unknown>,
chatSessionId?: string,
timeoutMs: number = CATTY_APPROVAL_TIMEOUT_MS,
capabilityId?: string,
): Promise<boolean> {
const request: ApprovalRequest = {
toolCallId,
toolName,
args,
chatSessionId,
capabilityId: capabilityId ?? resolveCapabilityId(toolName),
};
if (isGrantedByRules(request)) {
return Promise.resolve(true);
}
const existing = pendingApprovals.get(toolCallId);
if (existing) {
return new Promise<boolean>((resolve) => {
const previousResolve = existing.resolve;
existing.resolve = (resolution) => {
previousResolve(resolution);
resolve(resolution.approved);
};
});
}
emitApprovalEvent('approval_requested', request);
return new Promise<boolean>((resolve) => {
let timerId: ReturnType<typeof setTimeout> | null = null;
const { idleMs, hardDeadlineMs } = resolveCattyApprovalDeadlines(timeoutMs);
// Hard ceiling from creation — review re-arms idle from now for idleMs
// but never past this bound.
const hardDeadlineAt = Date.now() + hardDeadlineMs;
const clearTimer = () => {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
};
const wrappedResolve = (resolution: ApprovalResolution) => {
clearTimer();
resolve(resolution.approved);
};
const denyTimedOut = () => {
const entry = pendingApprovals.get(toolCallId);
if (!entry) return;
pendingApprovals.delete(toolCallId);
wrappedResolve({ approved: false, scope: 'once', cancelled: false });
emitApprovalEvent('approval_resolved', request, { outcome: 'timeout' });
for (const cl of clearedListeners) {
try { cl([toolCallId]); } catch { /* ignore */ }
}
};
const armTimer = (ms: number) => {
clearTimer();
if (ms <= 0) {
// Hard deadline already elapsed — reject synchronously so a late
// approve cannot race a deferred setTimeout(0) deny.
denyTimedOut();
return;
}
timerId = setTimeout(denyTimedOut, ms);
};
// Initial arm is the idle window. Review re-arms idle (capped by hard deadline).
armTimer(idleMs);
pendingApprovals.set(toolCallId, {
resolve: wrappedResolve,
request,
cancelTimeout: () => {
const remainingHard = hardDeadlineAt - Date.now();
if (remainingHard <= 0) {
armTimer(0);
return;
}
// Re-arm a full idle window from now, never past the hard deadline.
armTimer(Math.min(idleMs, remainingHard));
},
});
// Notify all UI listeners
for (const listener of listeners) {
try { listener(request); } catch { /* ignore listener errors */ }
}
});
}
/**
* Cancel / reset the idle auto-deny timer after the user starts reviewing or
* interacting with the card. Catty local approvals re-arm idle from *now* for
* a fresh idleMs window, never past the hard creation deadline.
* Timeout never auto-approves.
*/
export function cancelApprovalTimeout(toolCallId: string): void {
const entry = pendingApprovals.get(toolCallId);
// Keep cancelTimeout registered so further review activity can re-arm idle.
entry?.cancelTimeout?.();
// MCP / Codex App Server approvals are timed in the main process.
// MCP cancel drops idle but keeps the absolute creation deadline.
if (toolCallId.startsWith('mcp_approval_')) {
const bridge = (window as unknown as {
netcatty?: { cancelMcpApprovalTimeout?: (id: string) => Promise<unknown> };
}).netcatty;
void bridge?.cancelMcpApprovalTimeout?.(toolCallId);
} else if (toolCallId.startsWith('codex_interaction_')) {
const bridge = (window as unknown as {
netcatty?: { cancelCodexAppServerInteractionTimeout?: (id: string) => Promise<unknown> };
}).netcatty;
void bridge?.cancelCodexAppServerInteractionTimeout?.(toolCallId);
}
}
/**
* Called from the UI when the user approves or rejects a tool execution.
* Handles both SDK tool calls (local Promise) and MCP tool calls (IPC to main process).
*/
export function resolveApproval(
toolCallId: string,
decision: boolean | ResolveApprovalOptions,
): void {
const approved = typeof decision === 'boolean' ? decision : decision.approved;
const persistGrant = typeof decision === 'boolean' ? undefined : decision.persistGrant;
const persistGrants = typeof decision === 'boolean'
? undefined
: (decision.persistGrants ?? (persistGrant ? [persistGrant] : undefined));
const resolution: ApprovalResolution = {
approved,
scope: typeof decision === 'boolean' ? 'once' : (decision.scope ?? 'once'),
cancelled: typeof decision === 'boolean' ? false : decision.cancelled === true,
};
const entry = pendingApprovals.get(toolCallId);
const request = entry?.request;
if (!entry) {
// Stale UI click after the map entry was already drained — do not fan out
// a second MCP IPC response for a missing approval.
return;
}
pendingApprovals.delete(toolCallId);
entry.resolve(resolution);
let persistedGrantId: string | undefined;
if (approved && request?.source !== 'codex-app-server' && persistGrants?.length) {
for (const grant of persistGrants) {
grantPersister?.(grant);
persistedGrantId = grant.id;
}
}
if (request) {
emitApprovalEvent('approval_resolved', request, {
outcome: approved ? 'approved' : 'denied',
persistedGrantId,
});
}
// MCP tool call: also forward response to main process via IPC
if (toolCallId.startsWith('mcp_approval_')) {
const bridge = (window as unknown as { netcatty?: { respondMcpApproval?: (id: string, approved: boolean) => Promise<unknown> } }).netcatty;
bridge?.respondMcpApproval?.(toolCallId, approved);
}
}
/**
* Subscribe to approval request events. Returns an unsubscribe function.
*/
export function onApprovalRequest(listener: ApprovalRequestListener): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
/**
* Subscribe to approval cleared/removed events. Returns an unsubscribe function.
* Fired when approvals are cleared (e.g. on session stop) or timed out,
* so the UI can remove stale approval cards.
*/
export function onApprovalCleared(listener: ApprovalClearedListener): () => void {
clearedListeners.add(listener);
return () => { clearedListeners.delete(listener); };
}
/**
* Replay all currently pending approval requests to a listener.
* Useful when ChatMessageList remounts after being unmounted — without this,
* approvals that fired while unmounted would be silently missed and the
* corresponding execute Promises would hang indefinitely.
*
* This covers both SDK and MCP approvals since both are stored in the same map.
*/
export function replayPendingApprovals(listener: ApprovalRequestListener): void {
for (const [, entry] of pendingApprovals) {
try { listener(entry.request); } catch { /* ignore */ }
}
}
export function registerExternalApproval(
request: ApprovalRequest,
onResolve: (resolution: ApprovalResolution) => void,
): void {
if (pendingApprovals.has(request.toolCallId)) return;
emitApprovalEvent('approval_requested', request);
pendingApprovals.set(request.toolCallId, { request, resolve: onResolve });
for (const listener of listeners) {
try { listener(request); } catch { /* ignore listener errors */ }
}
}
export function clearPendingApprovalIds(toolCallIds: string[]): void {
const clearedIds: string[] = [];
for (const toolCallId of toolCallIds) {
if (pendingApprovals.delete(toolCallId)) clearedIds.push(toolCallId);
}
if (clearedIds.length > 0) {
for (const listener of clearedListeners) {
try { listener(clearedIds); } catch { /* ignore listener errors */ }
}
}
}
/**
* Check if a specific toolCallId has a pending approval.
*/
export function hasPendingApproval(toolCallId: string): boolean {
return pendingApprovals.has(toolCallId);
}
/**
* Clear pending approvals, optionally scoped to a specific chatSessionId.
* Resolves matching entries with `false` (denied) so execute functions don't hang.
* Also notifies cleared-listeners so the UI can remove stale approval cards.
*
* When chatSessionId is provided, only approvals belonging to that session
* are cleared — preventing cross-session interference in concurrent chats.
* When omitted, all pending approvals are cleared (backward-compatible).
*/
export function clearAllPendingApprovals(chatSessionId?: string): void {
const clearedIds: string[] = [];
if (!chatSessionId) {
// Clear everything (legacy / global stop)
for (const [id, entry] of pendingApprovals) {
entry.resolve({ approved: false, scope: 'once', cancelled: true });
clearedIds.push(id);
}
pendingApprovals.clear();
} else {
// Scoped clear: only remove approvals for this chatSessionId
for (const [id, entry] of pendingApprovals) {
if (entry.request.chatSessionId === chatSessionId) {
pendingApprovals.delete(id);
entry.resolve({ approved: false, scope: 'once', cancelled: true });
clearedIds.push(id);
}
}
}
// Notify UI listeners to remove the cards
if (clearedIds.length > 0) {
for (const cl of clearedListeners) {
try { cl(clearedIds); } catch { /* ignore */ }
}
}
}
/**
* Set up a bridge to receive MCP/SDK-agent approval requests from the Electron main process.
* Subscribes to IPC events and stores them in the same pendingApprovals map,
* so the same ToolCall UI handles both SDK and MCP approvals, and approvals
* survive ChatMessageList unmount/remount cycles via replayPendingApprovals().
*
* IMPORTANT: Call this from a component that stays mounted for the lifetime of
* the AI panel (e.g. AIChatSidePanel), NOT from ChatMessageList which unmounts
* on tab switches.
*
* Returns an unsubscribe function.
*/
export function setupMcpApprovalBridge(): () => void {
const bridge = (window as unknown as {
netcatty?: {
onMcpApprovalRequest?: (cb: (payload: {
approvalId: string;
toolName: string;
args: Record<string, unknown>;
chatSessionId?: string;
}) => void) => () => void;
onMcpApprovalCleared?: (cb: (payload: {
approvalIds: string[];
}) => void) => () => void;
};
}).netcatty;
if (!bridge?.onMcpApprovalRequest) return () => {};
const unsubRequest = bridge.onMcpApprovalRequest((payload) => {
const request: ApprovalRequest = {
toolCallId: payload.approvalId,
toolName: payload.toolName,
args: payload.args,
chatSessionId: payload.chatSessionId,
capabilityId: resolveCapabilityId(payload.toolName),
source: 'mcp',
};
// Store in pendingApprovals so it survives unmount/remount
// The resolve is a no-op because MCP approval resolution goes through IPC
// (handled in resolveApproval when toolCallId starts with 'mcp_approval_')
if (!pendingApprovals.has(payload.approvalId)) {
pendingApprovals.set(payload.approvalId, {
resolve: () => {}, // no-op; real resolution is via IPC
request,
});
}
// Notify all UI listeners
for (const listener of listeners) {
try { listener(request); } catch { /* ignore listener errors */ }
}
});
// Subscribe to main-process approval cleared events (timeout, cancel)
// so stale approval cards are removed from the renderer UI.
const unsubCleared = bridge.onMcpApprovalCleared?.((payload) => {
const clearedIds: string[] = [];
for (const id of payload.approvalIds) {
if (pendingApprovals.has(id)) {
pendingApprovals.delete(id);
clearedIds.push(id);
}
}
if (clearedIds.length > 0) {
for (const cl of clearedListeners) {
try { cl(clearedIds); } catch { /* ignore */ }
}
}
});
return () => {
unsubRequest();
unsubCleared?.();
};
}

View File

@@ -0,0 +1,29 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { bashArityPrefix } from './bashArity';
describe('bashArity (OpenCode parity)', () => {
it('unknown commands default to first token', () => {
assert.deepEqual(bashArityPrefix(['unknown', 'command', 'subcommand']), ['unknown']);
assert.deepEqual(bashArityPrefix(['touch', 'foo.txt']), ['touch']);
});
it('two token commands', () => {
assert.deepEqual(bashArityPrefix(['git', 'checkout', 'main']), ['git', 'checkout']);
assert.deepEqual(bashArityPrefix(['docker', 'run', 'nginx']), ['docker', 'run']);
});
it('three token commands', () => {
assert.deepEqual(bashArityPrefix(['aws', 's3', 'ls', 'my-bucket']), ['aws', 's3', 'ls']);
assert.deepEqual(bashArityPrefix(['npm', 'run', 'dev', 'script']), ['npm', 'run', 'dev']);
});
it('longest match wins', () => {
assert.deepEqual(bashArityPrefix(['docker', 'compose', 'up', 'service']), ['docker', 'compose', 'up']);
assert.deepEqual(bashArityPrefix(['consul', 'kv', 'get', 'config']), ['consul', 'kv', 'get']);
});
it('lscpu uses first token only', () => {
assert.deepEqual(bashArityPrefix(['lscpu']), ['lscpu']);
});
});

View File

@@ -0,0 +1,163 @@
export function prefix(tokens: string[]) {
for (let len = tokens.length; len > 0; len--) {
const prefix = tokens.slice(0, len).join(" ")
const arity = ARITY[prefix]
if (arity !== undefined) return tokens.slice(0, arity)
}
if (tokens.length === 0) return []
return tokens.slice(0, 1)
}
/* Generated with following prompt:
You are generating a dictionary of command-prefix arities for bash-style commands.
This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string → number**, representing how many **tokens** define the command.
2. **Flags NEVER count as tokens**. Only subcommands count.
3. **Longest matching prefix wins**.
4. **Only include a longer prefix if its arity is different from what the shorter prefix already implies**. * Example: If `git` is 2, then do **not** include `git checkout`, `git commit`, etc. unless they require *different* arity.
5. The output must be a **single JSON object**. Each entry should have a comment with an example real world matching command. DO NOT MAKE ANY OTHER COMMENTS. Should be alphabetical
6. Include the **most commonly used commands** across many stacks and languages. More is better.### **Semantics examples*** `touch foo.txt` → `touch` (arity 1, explicitly listed)
* `git checkout main` → `git checkout` (because `git` has arity 2)
* `npm install` → `npm install` (because `npm` has arity 2)
* `npm run dev` → `npm run dev` (because `npm run` has arity 3)
* `python script.py` → `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.**
*/
const ARITY: Record<string, number> = {
cat: 1, // cat file.txt
cd: 1, // cd /path/to/dir
chmod: 1, // chmod 755 script.sh
chown: 1, // chown user:group file.txt
cp: 1, // cp source.txt dest.txt
echo: 1, // echo "hello world"
env: 1, // env
export: 1, // export PATH=/usr/bin
grep: 1, // grep pattern file.txt
kill: 1, // kill 1234
killall: 1, // killall process
ln: 1, // ln -s source target
ls: 1, // ls -la
mkdir: 1, // mkdir new-dir
mv: 1, // mv old.txt new.txt
ps: 1, // ps aux
pwd: 1, // pwd
rm: 1, // rm file.txt
rmdir: 1, // rmdir empty-dir
sleep: 1, // sleep 5
source: 1, // source ~/.bashrc
tail: 1, // tail -f log.txt
touch: 1, // touch file.txt
unset: 1, // unset VAR
which: 1, // which node
aws: 3, // aws s3 ls
az: 3, // az storage blob list
bazel: 2, // bazel build
brew: 2, // brew install node
bun: 2, // bun install
"bun run": 3, // bun run dev
"bun x": 3, // bun x vite
cargo: 2, // cargo build
"cargo add": 3, // cargo add tokio
"cargo run": 3, // cargo run main
cdk: 2, // cdk deploy
cf: 2, // cf push app
cmake: 2, // cmake build
composer: 2, // composer require laravel
consul: 2, // consul members
"consul kv": 3, // consul kv get config/app
crictl: 2, // crictl ps
deno: 2, // deno run server.ts
"deno task": 3, // deno task dev
doctl: 3, // doctl kubernetes cluster list
docker: 2, // docker run nginx
"docker builder": 3, // docker builder prune
"docker compose": 3, // docker compose up
"docker container": 3, // docker container ls
"docker image": 3, // docker image prune
"docker network": 3, // docker network inspect
"docker volume": 3, // docker volume ls
eksctl: 2, // eksctl get clusters
"eksctl create": 3, // eksctl create cluster
firebase: 2, // firebase deploy
flyctl: 2, // flyctl deploy
gcloud: 3, // gcloud compute instances list
gh: 3, // gh pr list
git: 2, // git checkout main
"git config": 3, // git config user.name
"git remote": 3, // git remote add origin
"git stash": 3, // git stash pop
go: 2, // go build
gradle: 2, // gradle build
helm: 2, // helm install mychart
heroku: 2, // heroku logs
hugo: 2, // hugo new site blog
ip: 2, // ip link show
"ip addr": 3, // ip addr show
"ip link": 3, // ip link set eth0 up
"ip netns": 3, // ip netns exec foo bash
"ip route": 3, // ip route add default via 1.1.1.1
kind: 2, // kind delete cluster
"kind create": 3, // kind create cluster
kubectl: 2, // kubectl get pods
"kubectl kustomize": 3, // kubectl kustomize overlays/dev
"kubectl rollout": 3, // kubectl rollout restart deploy/api
kustomize: 2, // kustomize build .
make: 2, // make build
mc: 2, // mc ls myminio
"mc admin": 3, // mc admin info myminio
minikube: 2, // minikube start
mongosh: 2, // mongosh test
mysql: 2, // mysql -u root
mvn: 2, // mvn compile
ng: 2, // ng generate component home
npm: 2, // npm install
"npm exec": 3, // npm exec vite
"npm init": 3, // npm init vue
"npm run": 3, // npm run dev
"npm view": 3, // npm view react version
nvm: 2, // nvm use 18
nx: 2, // nx build
openssl: 2, // openssl genrsa 2048
"openssl req": 3, // openssl req -new -key key.pem
"openssl x509": 3, // openssl x509 -in cert.pem
pip: 2, // pip install numpy
pipenv: 2, // pipenv install flask
pnpm: 2, // pnpm install
"pnpm dlx": 3, // pnpm dlx create-next-app
"pnpm exec": 3, // pnpm exec vite
"pnpm run": 3, // pnpm run dev
poetry: 2, // poetry add requests
podman: 2, // podman run alpine
"podman container": 3, // podman container ls
"podman image": 3, // podman image prune
psql: 2, // psql -d mydb
pulumi: 2, // pulumi up
"pulumi stack": 3, // pulumi stack output
pyenv: 2, // pyenv install 3.11
python: 2, // python -m venv env
rake: 2, // rake db:migrate
rbenv: 2, // rbenv install 3.2.0
"redis-cli": 2, // redis-cli ping
rustup: 2, // rustup update
serverless: 2, // serverless invoke
sfdx: 3, // sfdx force:org:list
skaffold: 2, // skaffold dev
sls: 2, // sls deploy
sst: 2, // sst deploy
swift: 2, // swift build
systemctl: 2, // systemctl restart nginx
terraform: 2, // terraform apply
"terraform workspace": 3, // terraform workspace select prod
tmux: 2, // tmux new -s dev
turbo: 2, // turbo run build
ufw: 2, // ufw allow 22
vault: 2, // vault login
"vault auth": 3, // vault auth list
"vault kv": 3, // vault kv get secret/api
vercel: 2, // vercel deploy
volta: 2, // volta install node
wp: 2, // wp plugin install
yarn: 2, // yarn add react
"yarn dlx": 3, // yarn dlx create-react-app
"yarn run": 3, // yarn run dev
}
export function bashArityPrefix(tokens: string[]): string[] { return prefix(tokens); }

View File

@@ -0,0 +1,112 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
clearCodebuddyElicitationsForChat,
completeCodebuddyElicitation,
onCodebuddyElicitation,
onCodebuddyElicitationCleared,
registerCodebuddyElicitation,
replayPendingCodebuddyElicitations,
respondCodebuddyElicitation,
type CodebuddyElicitation,
} from './codebuddyElicitations';
test('CodeBuddy elicitation gate replays, responds, completes, and clears by chat', async () => {
const previousWindow = globalThis.window;
const responses: unknown[][] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
netcatty: {
aiSdkAgentElicitationResponse: async (...args: unknown[]) => {
responses.push(args);
return { ok: true };
},
},
},
});
const received: CodebuddyElicitation[] = [];
const cleared: string[][] = [];
const unsubscribe = onCodebuddyElicitation((elicitation) => received.push(elicitation));
const unsubscribeCleared = onCodebuddyElicitationCleared((ids) => cleared.push(ids));
registerCodebuddyElicitation({
elicitationId: 'el-1',
chatSessionId: 'chat-1',
request: {
message: 'Confirm deployment?',
requestedSchema: {
type: 'object',
properties: { environment: { type: 'string' } },
required: ['environment'],
},
},
});
assert.equal(received[0]?.elicitationId, 'el-1');
const replayed: CodebuddyElicitation[] = [];
replayPendingCodebuddyElicitations((elicitation) => replayed.push(elicitation));
assert.equal(replayed[0]?.request.message, 'Confirm deployment?');
await respondCodebuddyElicitation('el-1', 'accept', { environment: 'staging' });
assert.deepEqual(responses[0], ['el-1', 'accept', { environment: 'staging' }]);
assert.deepEqual(cleared[0], ['el-1']);
registerCodebuddyElicitation({
elicitationId: 'el-2',
chatSessionId: 'chat-1',
request: { message: 'Wait for completion' },
});
completeCodebuddyElicitation({ elicitationId: 'el-2' });
assert.deepEqual(cleared[1], ['el-2']);
registerCodebuddyElicitation({
elicitationId: 'el-3',
chatSessionId: 'chat-1',
request: {},
});
registerCodebuddyElicitation({
elicitationId: 'el-4',
chatSessionId: 'chat-2',
request: {},
});
clearCodebuddyElicitationsForChat('chat-1');
assert.deepEqual(cleared[2], ['el-3']);
const remaining: CodebuddyElicitation[] = [];
replayPendingCodebuddyElicitations((elicitation) => remaining.push(elicitation));
assert.deepEqual(remaining.map((elicitation) => elicitation.elicitationId), ['el-4']);
clearCodebuddyElicitationsForChat('chat-2');
unsubscribe();
unsubscribeCleared();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: previousWindow,
});
});
test('CodeBuddy elicitation registration assigns a new instance to reused protocol ids', () => {
const received: CodebuddyElicitation[] = [];
const unsubscribe = onCodebuddyElicitation((elicitation) => received.push(elicitation));
registerCodebuddyElicitation({
elicitationId: 'reused-id',
chatSessionId: 'chat-1',
request: { message: 'First request' },
});
registerCodebuddyElicitation({
elicitationId: 'reused-id',
chatSessionId: 'chat-1',
request: { message: 'Replacement request' },
});
assert.equal(received.length, 2);
assert.equal(typeof received[0].requestInstanceId, 'number');
assert.equal(typeof received[1].requestInstanceId, 'number');
assert.notEqual(received[0].requestInstanceId, received[1].requestInstanceId);
completeCodebuddyElicitation({ elicitationId: 'reused-id' });
unsubscribe();
});

View File

@@ -0,0 +1,102 @@
export type CodebuddyElicitationAction = 'accept' | 'decline' | 'cancel';
export interface CodebuddyElicitationRequest {
sessionId?: string;
toolCallId?: string;
mode?: string;
message?: string;
requestedSchema?: Record<string, unknown>;
_meta?: Record<string, unknown>;
}
export interface CodebuddyElicitation {
elicitationId: string;
chatSessionId: string;
request: CodebuddyElicitationRequest;
requestInstanceId?: number;
}
type ElicitationListener = (elicitation: CodebuddyElicitation) => void;
type ClearedListener = (elicitationIds: string[]) => void;
const pendingElicitations = new Map<string, CodebuddyElicitation>();
const listeners = new Set<ElicitationListener>();
const clearedListeners = new Set<ClearedListener>();
let nextRequestInstanceId = 0;
function notifyCleared(elicitationIds: string[]): void {
if (elicitationIds.length === 0) return;
for (const listener of clearedListeners) {
try { listener(elicitationIds); } catch { /* ignore listener failures */ }
}
}
export function registerCodebuddyElicitation(elicitation: CodebuddyElicitation): void {
if (!elicitation.elicitationId) return;
const registeredElicitation = {
...elicitation,
requestInstanceId: ++nextRequestInstanceId,
};
pendingElicitations.set(elicitation.elicitationId, registeredElicitation);
for (const listener of listeners) {
try { listener(registeredElicitation); } catch { /* ignore listener failures */ }
}
}
export function completeCodebuddyElicitation(notification: Record<string, unknown>): void {
const elicitationId = String(notification.elicitationId || '');
if (!elicitationId || !pendingElicitations.delete(elicitationId)) return;
notifyCleared([elicitationId]);
}
export function clearCodebuddyElicitationsForChat(chatSessionId: string): void {
const cleared: string[] = [];
for (const [elicitationId, elicitation] of pendingElicitations) {
if (elicitation.chatSessionId !== chatSessionId) continue;
pendingElicitations.delete(elicitationId);
cleared.push(elicitationId);
}
notifyCleared(cleared);
}
export function onCodebuddyElicitation(listener: ElicitationListener): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
export function onCodebuddyElicitationCleared(listener: ClearedListener): () => void {
clearedListeners.add(listener);
return () => { clearedListeners.delete(listener); };
}
export function replayPendingCodebuddyElicitations(listener: ElicitationListener): void {
for (const elicitation of pendingElicitations.values()) {
try { listener(elicitation); } catch { /* ignore listener failures */ }
}
}
export async function respondCodebuddyElicitation(
elicitationId: string,
action: CodebuddyElicitationAction,
content?: Record<string, unknown>,
): Promise<void> {
const bridge = (window as unknown as {
netcatty?: {
aiSdkAgentElicitationResponse?: (
id: string,
responseAction: CodebuddyElicitationAction,
responseContent?: Record<string, unknown>,
) => Promise<{ ok: boolean; error?: string }>;
};
}).netcatty;
if (!bridge?.aiSdkAgentElicitationResponse) {
throw new Error('CodeBuddy elicitation bridge is unavailable');
}
const result = await bridge.aiSdkAgentElicitationResponse(elicitationId, action, content);
if (!result?.ok) {
throw new Error(result?.error || 'Failed to answer CodeBuddy elicitation');
}
if (pendingElicitations.delete(elicitationId)) {
notifyCleared([elicitationId]);
}
}

View File

@@ -0,0 +1,150 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
onCodexAppServerInteraction,
replayPendingCodexAppServerInteractions,
respondCodexUserInput,
setupCodexAppServerInteractionBridge,
type CodexAppServerInteraction,
} from './codexAppServerInteractions';
import {
clearAllPendingApprovals,
onApprovalRequest,
replayPendingApprovals,
resolveApproval,
type ApprovalRequest,
} from './approvalGate';
test('Codex App Server interaction gate replays requests and forwards typed responses', async () => {
let requestListener: ((payload: CodexAppServerInteraction) => void) | undefined;
let clearedListener: ((payload: { interactionIds: string[] }) => void) | undefined;
const responses: Record<string, unknown>[] = [];
const previousWindow = globalThis.window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
netcatty: {
onCodexAppServerInteractionRequest: (listener: typeof requestListener) => {
requestListener = listener;
return () => {};
},
onCodexAppServerInteractionCleared: (listener: typeof clearedListener) => {
clearedListener = listener;
return () => {};
},
respondCodexAppServerInteraction: async (payload: Record<string, unknown>) => {
responses.push(payload);
return { ok: true };
},
},
},
});
const teardown = setupCodexAppServerInteractionBridge();
const received: CodexAppServerInteraction[] = [];
const approvals: ApprovalRequest[] = [];
const unsubscribe = onCodexAppServerInteraction((interaction) => received.push(interaction));
const unsubscribeApprovals = onApprovalRequest((approval) => approvals.push(approval));
requestListener?.({
interactionId: 'approval-1',
source: 'codex-app-server',
kind: 'command',
requestId: 'request-1',
chatSessionId: 'chat-1',
toolName: 'codex.command',
args: { command: 'npm test' },
availableDecisions: ['accept', 'decline', 'cancel'],
});
assert.equal(received.length, 0);
assert.equal(approvals[0].source, 'codex-app-server');
assert.equal(approvals[0].allowSession, false);
const replayedApprovals: ApprovalRequest[] = [];
replayPendingApprovals((approval) => replayedApprovals.push(approval));
assert.equal(replayedApprovals[0].toolCallId, 'approval-1');
resolveApproval('approval-1', { approved: true, scope: 'once' });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(responses[0], { interactionId: 'approval-1', decision: 'once' });
requestListener?.({
interactionId: 'approval-session',
source: 'codex-app-server',
kind: 'command',
requestId: 'request-1',
chatSessionId: 'chat-1',
toolName: 'codex.command',
args: { command: 'npm test' },
availableDecisions: ['accept', 'acceptForSession', 'decline', 'cancel'],
});
assert.equal(approvals[1].allowSession, true);
resolveApproval('approval-session', { approved: true, scope: 'session' });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(responses[1], { interactionId: 'approval-session', decision: 'session' });
requestListener?.({
interactionId: 'approval-stop',
source: 'codex-app-server',
kind: 'file-change',
requestId: 'request-1',
chatSessionId: 'chat-1',
toolName: 'codex.file_change',
args: { reason: 'write files' },
});
clearAllPendingApprovals('chat-1');
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(responses[2], { interactionId: 'approval-stop', decision: 'cancel' });
requestListener?.({
interactionId: 'input-1',
source: 'codex-app-server',
kind: 'user-input',
requestId: 'request-1',
chatSessionId: 'chat-1',
questions: [],
});
assert.equal(received[0].interactionId, 'input-1');
const replayedInputs: CodexAppServerInteraction[] = [];
replayPendingCodexAppServerInteractions((interaction) => replayedInputs.push(interaction));
assert.equal(replayedInputs[0].interactionId, 'input-1');
await respondCodexUserInput('input-1', { mode: { answers: ['safe'] } });
assert.deepEqual(responses[3], {
interactionId: 'input-1',
answers: { mode: { answers: ['safe'] } },
});
clearedListener?.({ interactionIds: ['missing'] });
unsubscribe();
unsubscribeApprovals();
teardown();
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
});
test('Codex App Server interaction bridge is ref-counted across setup/teardown', () => {
let subscribeCount = 0;
let unsubscribeCount = 0;
const previousWindow = globalThis.window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
netcatty: {
onCodexAppServerInteractionRequest: () => {
subscribeCount += 1;
return () => {
unsubscribeCount += 1;
};
},
onCodexAppServerInteractionCleared: () => () => {},
},
},
});
const first = setupCodexAppServerInteractionBridge();
const second = setupCodexAppServerInteractionBridge();
assert.equal(subscribeCount, 1, 'nested setup must not stack ipc listeners');
first();
assert.equal(unsubscribeCount, 0, 'first teardown while second still live keeps the bridge');
second();
assert.equal(unsubscribeCount, 1);
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
});

View File

@@ -0,0 +1,198 @@
import {
clearPendingApprovalIds,
registerExternalApproval,
} from './approvalGate';
export type CodexApprovalDecision = 'once' | 'session' | 'reject' | 'cancel';
export type CodexCommandApprovalDecision =
| 'accept'
| 'acceptForSession'
| 'decline'
| 'cancel'
| Record<string, unknown>;
export interface CodexUserInputQuestion {
id: string;
header: string;
question: string;
isOther: boolean;
isSecret: boolean;
options: Array<{ label: string; description: string }> | null;
}
export type CodexAppServerInteraction =
| {
interactionId: string;
source: 'codex-app-server';
kind: 'command' | 'file-change' | 'permissions';
requestId: string;
chatSessionId: string;
itemId?: string;
toolName: string;
args: Record<string, unknown>;
availableDecisions?: CodexCommandApprovalDecision[];
}
| {
interactionId: string;
source: 'codex-app-server';
kind: 'user-input';
requestId: string;
chatSessionId: string;
itemId?: string;
questions: CodexUserInputQuestion[];
autoResolutionMs?: number | null;
};
type InteractionListener = (interaction: CodexAppServerInteraction) => void;
type ClearedListener = (interactionIds: string[]) => void;
const pendingInteractions = new Map<string, CodexAppServerInteraction>();
const listeners = new Set<InteractionListener>();
const clearedListeners = new Set<ClearedListener>();
function registerCodexApproval(
interaction: Extract<CodexAppServerInteraction, { kind: 'command' | 'file-change' | 'permissions' }>,
): void {
const allowSession = interaction.kind === 'command'
? interaction.availableDecisions?.includes('acceptForSession') === true
: true;
registerExternalApproval({
toolCallId: interaction.interactionId,
itemId: interaction.itemId,
toolName: interaction.toolName,
args: interaction.args,
chatSessionId: interaction.chatSessionId,
source: 'codex-app-server',
approvalType: interaction.kind,
allowSession,
}, (resolution) => {
const decision: CodexApprovalDecision = resolution.cancelled
? 'cancel'
: resolution.approved
? resolution.scope
: 'reject';
void respondCodexApproval(interaction.interactionId, decision).catch((error) => {
console.error('[Codex App Server] Failed to respond to approval:', error);
if (pendingInteractions.has(interaction.interactionId)) registerCodexApproval(interaction);
});
});
}
function notifyCleared(interactionIds: string[]): void {
if (interactionIds.length === 0) return;
for (const listener of clearedListeners) {
try { listener(interactionIds); } catch { /* ignore listener failures */ }
}
}
let bridgeInstallCount = 0;
let bridgeTeardown: (() => void) | null = null;
function installCodexAppServerInteractionBridge(): () => void {
const bridge = (window as unknown as {
netcatty?: {
onCodexAppServerInteractionRequest?: (
cb: (payload: CodexAppServerInteraction) => void,
) => () => void;
onCodexAppServerInteractionCleared?: (
cb: (payload: { interactionIds: string[] }) => void,
) => () => void;
};
}).netcatty;
if (!bridge?.onCodexAppServerInteractionRequest) return () => {};
const unsubscribeRequest = bridge.onCodexAppServerInteractionRequest((interaction) => {
if (!interaction?.interactionId) return;
pendingInteractions.set(interaction.interactionId, interaction);
if (interaction.kind !== 'user-input') {
registerCodexApproval(interaction);
return;
}
for (const listener of listeners) {
try { listener(interaction); } catch { /* ignore listener failures */ }
}
});
const unsubscribeCleared = bridge.onCodexAppServerInteractionCleared?.((payload) => {
const cleared: string[] = [];
for (const interactionId of payload?.interactionIds || []) {
if (pendingInteractions.delete(interactionId)) cleared.push(interactionId);
}
clearPendingApprovalIds(cleared);
notifyCleared(cleared);
});
return () => {
unsubscribeRequest();
unsubscribeCleared?.();
};
}
/**
* App-singleton IPC bridge (same pattern as setupMcpApprovalBridge).
* Ref-counted so StrictMode remount / accidental multi-mount does not stack
* ipcRenderer listeners.
*/
export function setupCodexAppServerInteractionBridge(): () => void {
bridgeInstallCount += 1;
if (bridgeInstallCount === 1) {
bridgeTeardown = installCodexAppServerInteractionBridge();
}
return () => {
bridgeInstallCount = Math.max(0, bridgeInstallCount - 1);
if (bridgeInstallCount === 0) {
bridgeTeardown?.();
bridgeTeardown = null;
}
};
}
export function onCodexAppServerInteraction(listener: InteractionListener): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
export function onCodexAppServerInteractionCleared(listener: ClearedListener): () => void {
clearedListeners.add(listener);
return () => { clearedListeners.delete(listener); };
}
export function replayPendingCodexAppServerInteractions(listener: InteractionListener): void {
for (const interaction of pendingInteractions.values()) {
if (interaction.kind !== 'user-input') continue;
try { listener(interaction); } catch { /* ignore listener failures */ }
}
}
async function respond(payload: Record<string, unknown>): Promise<void> {
const interactionId = String(payload.interactionId || '');
if (!interactionId) return;
const bridge = (window as unknown as {
netcatty?: {
respondCodexAppServerInteraction?: (
response: Record<string, unknown>,
) => Promise<unknown>;
};
}).netcatty;
if (!bridge?.respondCodexAppServerInteraction) {
throw new Error('Codex App Server interaction bridge is unavailable');
}
const result = await bridge.respondCodexAppServerInteraction(payload) as { ok?: boolean; error?: string } | undefined;
if (result?.ok === false) {
throw new Error(result.error || 'Failed to respond to Codex App Server interaction');
}
pendingInteractions.delete(interactionId);
notifyCleared([interactionId]);
}
export function respondCodexApproval(
interactionId: string,
decision: CodexApprovalDecision,
): Promise<void> {
return respond({ interactionId, decision });
}
export function respondCodexUserInput(
interactionId: string,
answers: Record<string, { answers: string[] }>,
): Promise<void> {
return respond({ interactionId, answers });
}

View File

@@ -0,0 +1,12 @@
/** True when the configured agent command looks like an explicit filesystem path. */
export function isPathLikeCommand(command: string | undefined): boolean {
const normalized = String(command || '').trim();
return normalized.includes('/') || normalized.includes('\\') || /^[a-z]:/i.test(normalized);
}
export function getCommandBasename(command: string | undefined): string {
const normalized = String(command || '').trim();
if (!normalized) return '';
const parts = normalized.split(/[\\/]/);
return (parts.pop() || '').toLowerCase();
}

View File

@@ -0,0 +1,127 @@
/**
* Per-session execution queue for tool calls that target the same terminal
* session.
*
* Background — issue #1101 problem 3:
*
* Vercel AI SDK dispatches every tool_use block emitted in one assistant
* turn through `Promise.all(toolCalls.map(execute))`, so an LLM that asks
* for three commands "at once" sends three simultaneous `bridge.aiExec()`
* calls at the underlying PTY. The main-process session mutex
* (`mcpServerBridge.reserveSessionExecution`) only lets one through and
* rejects the rest with `{ ok: false, error: "Session already has another
* command in progress..." }`. The LLM then sees two synthetic errors plus
* one real result for a turn it expected to be all-or-nothing — and the
* Anthropic API has occasionally rejected the resulting trace with a
* `tool_use ids were found without tool_result blocks` 400.
*
* The cleanest fix is to never let those calls race in the first place:
* serialize at the renderer-side tool execute boundary so the bridge sees
* one command per session at a time. The bridge mutex stays as
* defense-in-depth for non-LLM IPC paths (terminal_start, MCP, etc.).
*
* The queue exposes both a high-level `chainBySessionKey(key, task)` for
* simple "run this when it's our turn" callers, and a lower-level
* `reserveSessionSlot(key)` for callers that need to do non-blocking work
* (e.g. await an approval prompt) *while* their queue slot is held — so
* the queue order matches the LLM's emission order independent of when
* each call's approval lands.
*/
const queues = new Map<string, Promise<unknown>>();
/**
* A reserved slot in a session's execution queue. The slot is added to
* the queue tail synchronously when {@link reserveSessionSlot} is called,
* so call order is fixed at reservation time — regardless of how long
* each caller spends on pre-work (approval prompts, abort checks, etc.)
* before they actually start.
*
* Lifecycle:
* 1. `reserveSessionSlot(key)` — synchronously snaps a place in line.
* 2. caller does whatever pre-work they want, in parallel with siblings.
* 3. `await slot.ready` — blocks until the previous slot has released.
* 4. caller does the serialized work.
* 5. `slot.release()` — frees the next slot. Idempotent.
*
* The slot **must** be released exactly once (typically from a `finally`)
* even if the caller decides to skip the serialized work — otherwise
* subsequent slots queued behind it never start.
*/
export interface SessionExecutionSlot {
/** Resolves when this slot is at the head of its queue. */
readonly ready: Promise<void>;
/** Releases this slot. Safe to call multiple times. */
release(): void;
}
export function reserveSessionSlot(key: string): SessionExecutionSlot {
const prev = queues.get(key) ?? Promise.resolve();
let resolveDone!: () => void;
const done = new Promise<void>((r) => {
resolveDone = r;
});
// The new tail of this key's queue: previous tail → our `done`.
// Wrap in a non-rejecting chain so a thrown task never poisons later
// callers waiting on this tail.
const tail: Promise<unknown> = prev.then(() => done).catch(() => undefined);
queues.set(key, tail);
// Best-effort cleanup once we're the last in line — keeps the map
// from growing without bound across many short-lived sessions. A
// later caller that arrived between `queues.set` and this finally
// will already have replaced the tail; we only clear when we're
// still it.
void tail.finally(() => {
if (queues.get(key) === tail) {
queues.delete(key);
}
});
let released = false;
return {
ready: prev.then(
() => undefined,
() => undefined,
),
release(): void {
if (released) return;
released = true;
resolveDone();
},
};
}
/**
* Run `task` after every previously-reserved slot with the same `key`
* has released. Returns the task's resolved value (or rejects if the
* task throws). A failure in one task does not poison the queue head
* for subsequent callers — the chain only waits on settlement, not
* success.
*
* For callers that need to interleave pre-work with the queue wait
* (e.g. approval prompts that should run in parallel even though the
* actual command must run serially), use {@link reserveSessionSlot}
* directly.
*/
export async function chainBySessionKey<T>(key: string, task: () => Promise<T>): Promise<T> {
const slot = reserveSessionSlot(key);
try {
await slot.ready;
return await task();
} finally {
slot.release();
}
}
/** Test-only: inspect the live queue. */
export function getSessionExecutionQueueSizeForTests(): number {
return queues.size;
}
/** Test-only: drop all queued work. */
export function resetSessionExecutionQueueForTests(): void {
queues.clear();
}

View File

@@ -0,0 +1,146 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { buildAlwaysAllowCommandPatterns } from './shellCommandGrant';
describe('shellCommandGrant (OpenCode always patterns)', () => {
it('builds prefix wildcard for simple commands', () => {
assert.deepEqual(buildAlwaysAllowCommandPatterns('lscpu'), ['lscpu *']);
assert.deepEqual(buildAlwaysAllowCommandPatterns('touch foo.txt'), ['touch *']);
});
it('builds subcommand-aware prefixes', () => {
assert.deepEqual(buildAlwaysAllowCommandPatterns('git checkout main'), ['git checkout *']);
assert.deepEqual(buildAlwaysAllowCommandPatterns('systemctl status nginx'), ['systemctl status *']);
assert.deepEqual(buildAlwaysAllowCommandPatterns('npm run dev'), ['npm run dev *']);
});
it('skips cd segments in chains but keeps others', () => {
assert.deepEqual(
buildAlwaysAllowCommandPatterns('cd /tmp && ls -la'),
['ls *'],
);
});
it('keeps cwd segments that execute shell substitutions grantable', () => {
assert.deepEqual(
buildAlwaysAllowCommandPatterns('cd "$(pwd)"; ls -la'),
['cd *', 'ls *'],
);
});
it('splits single ampersand background commands', () => {
assert.deepEqual(
buildAlwaysAllowCommandPatterns('cd /tmp; sleep 1 & rm -rf demo'),
['sleep *', 'rm *'],
);
});
it('ignores comments when building grants for multiline commands', () => {
const command = [
'# 1a) clear the kernel_options_post profile field',
'cobbler profile edit --name=openEuler-22.03-aarch64 --kernel-options-post=""',
'',
'# verify',
"cobbler profile report --name=openEuler-22.03-aarch64 | grep -i 'kernel.options'",
].join('\n');
assert.deepEqual(buildAlwaysAllowCommandPatterns(command), ['cobbler *', 'grep *']);
});
it('does not build grants from here-doc body lines', () => {
const command = [
"cat <<'EOF'",
'rm -rf /tmp/demo',
'EOF',
].join('\n');
assert.deepEqual(buildAlwaysAllowCommandPatterns(command), ['cat *']);
});
it('does not build grants from piped here-doc body lines', () => {
const command = [
'cat <<EOF | grep needle',
'rm -rf /tmp/demo',
'EOF',
].join('\n');
assert.deepEqual(buildAlwaysAllowCommandPatterns(command), ['cat *', 'grep *']);
});
it('does not build grants from fd-prefixed here-doc body lines', () => {
assert.deepEqual(
buildAlwaysAllowCommandPatterns([
'cat 0<<EOF',
'rm -rf /tmp/demo',
'EOF',
].join('\n')),
['cat *'],
);
assert.deepEqual(
buildAlwaysAllowCommandPatterns([
'cat 3<<-EOF | grep needle',
'\trm -rf /tmp/demo',
'\tEOF',
].join('\n')),
['cat *', 'grep *'],
);
});
it('does not treat quoted here-doc operator text as a here-doc', () => {
assert.deepEqual(
buildAlwaysAllowCommandPatterns([
"cd /tmp; echo '<<EOF'",
'rm -rf demo',
'EOF',
].join('\n')),
['echo *', 'rm *', 'EOF *'],
);
});
it('resumes parsing after mixed-quoted here-doc delimiters', () => {
assert.deepEqual(
buildAlwaysAllowCommandPatterns([
'cat <<E"OF"',
'body text',
'EOF',
'ls -la',
].join('\n')),
['cat *', 'ls *'],
);
});
it('resumes parsing after dollar-quoted here-doc delimiters', () => {
assert.deepEqual(
buildAlwaysAllowCommandPatterns([
"cat <<$'EOF'",
'body text',
'EOF',
'rm -rf demo',
].join('\n')),
['cat *', 'rm *'],
);
});
it('resumes parsing after ANSI-C quoted here-doc delimiters', () => {
assert.deepEqual(
buildAlwaysAllowCommandPatterns([
"cat <<$'E\\x4fF'",
'body text',
'EOF',
'rm -rf demo',
].join('\n')),
['cat *', 'rm *'],
);
});
it('does not treat arithmetic shifts as here-doc delimiters', () => {
assert.deepEqual(
buildAlwaysAllowCommandPatterns([
'ls $((1 << 2))',
'rm -rf demo',
].join('\n')),
['ls *', 'rm *'],
);
});
});

View File

@@ -0,0 +1,495 @@
import { bashArityPrefix } from './bashArity';
/** Commands whose always-allow patterns are skipped (OpenCode shell.ts CWD set). */
const CWD_COMMANDS = new Set([
'cd',
'chdir',
'popd',
'pushd',
'push-location',
'set-location',
]);
export function unquoteShellToken(token: string): string {
if (token.length >= 2) {
const first = token[0];
const last = token[token.length - 1];
if ((first === '"' || first === "'") && first === last) {
return token.slice(1, -1);
}
}
return token;
}
export function tokenizeShellCommand(command: string): string[] {
const matches = command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
return matches.map(unquoteShellToken);
}
type HereDocTerminator = {
text: string;
stripLeadingTabs: boolean;
};
function lastNonWhitespaceChar(value: string): string | undefined {
return value.match(/\S(?=\s*$)/)?.[0];
}
function readArithmeticExpansionEnd(segment: string, startIndex: number): number | null {
if (segment[startIndex] !== '$' || segment[startIndex + 1] !== '(' || segment[startIndex + 2] !== '(') {
return null;
}
let depth = 1;
let quote: '"' | "'" | '`' | null = null;
for (let index = startIndex + 3; index < segment.length; index += 1) {
const char = segment[index]!;
const next = segment[index + 1];
if (quote) {
if (char === '\\' && quote !== "'" && next) {
index += 1;
continue;
}
if (char === quote) quote = null;
continue;
}
if (char === '\\' && next) {
index += 1;
continue;
}
if (char === '"' || char === "'" || char === '`') {
quote = char;
continue;
}
if (char === '(') {
depth += 1;
continue;
}
if (char === ')') {
if (depth === 1 && next === ')') return index + 2;
if (depth > 1) depth -= 1;
}
}
return segment.length;
}
function hasExecutableShellExpansion(segment: string): boolean {
let quote: '"' | "'" | null = null;
for (let index = 0; index < segment.length; index += 1) {
const char = segment[index]!;
const next = segment[index + 1];
if (quote) {
if (char === '\\' && quote === '"' && next) {
index += 1;
continue;
}
if (char === quote) {
quote = null;
continue;
}
if (quote === '"' && char === '$' && next === '(') return true;
if (quote === '"' && char === '`') return true;
continue;
}
if (char === '\\' && next) {
index += 1;
continue;
}
if (char === "'") {
quote = char;
continue;
}
if (char === '"') {
quote = char;
continue;
}
if (char === '`') return true;
if (char === '$' && next === '(') return true;
if ((char === '<' || char === '>') && next === '(') return true;
}
return false;
}
function readEscapeDigits(
value: string,
startIndex: number,
maxLength: number,
pattern: RegExp,
): { digits: string; endIndex: number } | null {
let endIndex = startIndex;
while (
endIndex < value.length
&& endIndex < startIndex + maxLength
&& pattern.test(value[endIndex]!)
) {
endIndex += 1;
}
if (endIndex === startIndex) return null;
return { digits: value.slice(startIndex, endIndex), endIndex: endIndex - 1 };
}
function codePointToString(codePoint: number): string {
try {
return String.fromCodePoint(codePoint);
} catch {
return '';
}
}
function readAnsiCEscape(value: string, backslashIndex: number): { text: string; endIndex: number } {
const escapeIndex = backslashIndex + 1;
const char = value[escapeIndex];
if (!char) return { text: '\\', endIndex: backslashIndex };
const simpleEscapes: Record<string, string> = {
a: '\x07',
b: '\b',
e: '\x1B',
E: '\x1B',
f: '\f',
n: '\n',
r: '\r',
t: '\t',
v: '\v',
'\\': '\\',
"'": "'",
'"': '"',
'?': '?',
};
const simple = simpleEscapes[char];
if (simple !== undefined) return { text: simple, endIndex: escapeIndex };
if (char === 'x') {
const digits = readEscapeDigits(value, escapeIndex + 1, 2, /[0-9a-fA-F]/);
if (!digits) return { text: '\\x', endIndex: escapeIndex };
return {
text: codePointToString(Number.parseInt(digits.digits, 16)),
endIndex: digits.endIndex,
};
}
if (char === 'u' || char === 'U') {
const digits = readEscapeDigits(value, escapeIndex + 1, char === 'u' ? 4 : 8, /[0-9a-fA-F]/);
if (!digits) return { text: `\\${char}`, endIndex: escapeIndex };
return {
text: codePointToString(Number.parseInt(digits.digits, 16)),
endIndex: digits.endIndex,
};
}
if (/[0-7]/.test(char)) {
const digits = readEscapeDigits(value, escapeIndex, 3, /[0-7]/)!;
return {
text: codePointToString(Number.parseInt(digits.digits, 8)),
endIndex: digits.endIndex,
};
}
return { text: `\\${char}`, endIndex: escapeIndex };
}
function readHereDocDelimiterWord(
segment: string,
startIndex: number,
): { text: string; endIndex: number } | null {
let index = startIndex;
while (index < segment.length && /\s/.test(segment[index]!)) index += 1;
let text = '';
let quote: '"' | "'" | '`' | null = null;
let ansiQuote = false;
for (; index < segment.length; index += 1) {
const char = segment[index]!;
const next = segment[index + 1];
if (quote) {
if (ansiQuote && char === '\\') {
const escape = readAnsiCEscape(segment, index);
text += escape.text;
index = escape.endIndex;
continue;
}
if (char === '\\' && quote !== "'" && next) {
text += next;
index += 1;
continue;
}
if (char === quote) {
quote = null;
ansiQuote = false;
continue;
}
text += char;
continue;
}
if (/\s/.test(char) || char === ';' || char === '|' || char === '&') break;
if (char === '\\' && next) {
text += next;
index += 1;
continue;
}
if (char === '$' && (next === "'" || next === '"')) {
quote = next;
ansiQuote = next === "'";
index += 1;
continue;
}
if (char === '"' || char === "'" || char === '`') {
quote = char;
ansiQuote = false;
continue;
}
text += char;
}
return text ? { text, endIndex: index } : null;
}
function extractHereDocTerminators(segment: string): HereDocTerminator[] {
const terminators: HereDocTerminator[] = [];
let quote: '"' | "'" | '`' | null = null;
for (let index = 0; index < segment.length; index += 1) {
const char = segment[index]!;
const next = segment[index + 1];
if (quote) {
if (char === '\\' && quote !== "'" && next) {
index += 1;
continue;
}
if (char === quote) quote = null;
continue;
}
const arithmeticEnd = readArithmeticExpansionEnd(segment, index);
if (arithmeticEnd !== null) {
index = arithmeticEnd - 1;
continue;
}
if (char === '\\' && next) {
index += 1;
continue;
}
if (char === '"' || char === "'" || char === '`') {
quote = char;
continue;
}
if (char !== '<' || next !== '<') continue;
if (segment[index + 2] === '<') {
index += 2;
continue;
}
const stripLeadingTabs = segment[index + 2] === '-';
const delimiterStart = index + (stripLeadingTabs ? 3 : 2);
const delimiter = readHereDocDelimiterWord(segment, delimiterStart);
if (!delimiter) continue;
terminators.push({ text: delimiter.text, stripLeadingTabs });
index = delimiter.endIndex - 1;
}
return terminators;
}
function skipHereDocBodies(
command: string,
startIndex: number,
terminators: HereDocTerminator[],
): number {
let cursor = startIndex;
for (const terminator of terminators) {
while (cursor < command.length) {
const lineEnd = command.indexOf('\n', cursor);
const end = lineEnd === -1 ? command.length : lineEnd;
const rawLine = command.slice(cursor, end);
const line = terminator.stripLeadingTabs ? rawLine.replace(/^\t+/, '') : rawLine;
cursor = lineEnd === -1 ? command.length : lineEnd + 1;
if (line === terminator.text) break;
}
}
return cursor;
}
export function splitShellCommandSegments(command: string): string[] {
const segments: string[] = [];
let current = '';
let quote: '"' | "'" | '`' | null = null;
let inComment = false;
let lineHereDocTerminators: HereDocTerminator[] = [];
const flush = () => {
const segment = current.trim();
if (segment) segments.push(segment);
current = '';
};
const flushCommandSegment = () => {
lineHereDocTerminators.push(...extractHereDocTerminators(current));
flush();
};
const finishLine = (bodyStartIndex: number): number => {
flushCommandSegment();
const hereDocTerminators = lineHereDocTerminators;
lineHereDocTerminators = [];
if (hereDocTerminators.length === 0) return bodyStartIndex;
return skipHereDocBodies(command, bodyStartIndex, hereDocTerminators);
};
for (let index = 0; index < command.length; index += 1) {
const char = command[index]!;
const next = command[index + 1];
if (inComment) {
if (char === '\n') {
inComment = false;
index = finishLine(index + 1) - 1;
}
continue;
}
if (quote) {
current += char;
if (char === '\\' && quote !== "'" && next) {
current += next;
index += 1;
continue;
}
if (char === quote) quote = null;
continue;
}
if (char === '\\' && next === '\n') {
index += 1;
continue;
}
if (char === '\\' && next) {
current += char + next;
index += 1;
continue;
}
if (char === '"' || char === "'" || char === '`') {
quote = char;
current += char;
continue;
}
const arithmeticEnd = readArithmeticExpansionEnd(command, index);
if (arithmeticEnd !== null) {
current += command.slice(index, arithmeticEnd);
index = arithmeticEnd - 1;
continue;
}
if (char === '#') {
const previous = current[current.length - 1];
if (!previous || /\s/.test(previous)) {
inComment = true;
continue;
}
}
if (char === '\n') {
index = finishLine(index + 1) - 1;
continue;
}
if (char === ';') {
flushCommandSegment();
continue;
}
if (char === '&' && next === '&') {
flushCommandSegment();
index += 1;
continue;
}
if (char === '|' && next === '|') {
flushCommandSegment();
index += 1;
continue;
}
if (char === '&') {
const previous = lastNonWhitespaceChar(current);
if (next === '>' || previous === '>' || previous === '<') {
current += char;
continue;
}
flushCommandSegment();
continue;
}
if (char === '|') {
flushCommandSegment();
if (next === '&') index += 1;
continue;
}
current += char;
}
flush();
return segments;
}
export function extractGrantableShellCommandSegments(command: string): string[] {
return splitShellCommandSegments(command).filter((segment) => {
const tokens = tokenizeShellCommand(segment);
const cmd = tokens[0]?.toLowerCase();
return tokens.length > 0 && !(cmd && CWD_COMMANDS.has(cmd) && !hasExecutableShellExpansion(segment));
});
}
/**
* OpenCode always-allow patterns: BashArity.prefix(tokens) + " *"
* @see packages/opencode/src/tool/shell.ts collect()
*/
export function buildAlwaysAllowCommandPatterns(command: string): string[] {
const trimmed = command.trim();
if (!trimmed) return [];
const patterns = new Set<string>();
const segments = extractGrantableShellCommandSegments(trimmed);
for (const segment of segments) {
const tokens = tokenizeShellCommand(segment);
const prefix = bashArityPrefix(tokens);
if (prefix.length === 0) continue;
patterns.add(`${prefix.join(' ')} *`);
}
return [...patterns];
}

View File

@@ -0,0 +1,50 @@
/**
* Helpers for detecting Vercel AI SDK internal stream-state errors.
*
* Background — issue #1101 follow-up:
*
* When a third-party Anthropic-compat backend (DeepSeek's
* `deepseek-v4-flash` is the canonical offender) streams thinking
* deltas without first emitting a `reasoning-start` content-block
* signal, the Vercel AI SDK's reasoning state machine has nothing
* registered for the incoming `part.id` and enqueues an
* `error` chunk on `stream` with the text
* `reasoning part <id> not found` — once per orphan delta. The
* analogous error exists for text parts.
*
* These chunks are *internal SDK bookkeeping noise*, not user-facing
* errors. Worse, treating them as real errors (adding a placeholder
* assistant message for each) breaks tool_use/tool_result contiguity
* on the next turn: the Anthropic message grouper splits the
* tool-result `role: 'tool'` messages from their parent tool_use
* `role: 'assistant'`, and the backend responds with
* `400 messages.N: tool_use ids were found without tool_result blocks
* immediately after`.
*
* Filtering these specific errors at the chunk-handler boundary
* stops the cascade: the orphan deltas are dropped silently (the SDK
* continues processing other chunks), no fake assistant messages
* land in history, and the next turn's request stays well-formed.
*/
const STATE_ERROR_PATTERN = /^(?:reasoning|text)\s+part\s+\S+\s+not\s+found$/i;
/**
* Return true if `error` is one of the SDK's internal stream-state
* tracking errors (e.g. an out-of-order reasoning delta). Accepts
* the loose `unknown` shape that comes off the chunk so callers
* don't need to narrow upstream.
*/
export function isSdkStreamStateError(error: unknown): boolean {
if (typeof error === 'string') {
return STATE_ERROR_PATTERN.test(error.trim());
}
if (error instanceof Error) {
return STATE_ERROR_PATTERN.test(error.message.trim());
}
if (error && typeof error === 'object' && 'message' in error) {
const msg = (error as { message?: unknown }).message;
if (typeof msg === 'string') return STATE_ERROR_PATTERN.test(msg.trim());
}
return false;
}

View File

@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_COMMAND_BLOCKLIST } from '../types';
import { executeTerminalExecute } from './toolExecutors';
function createDeps(shellType?: string) {
const calls: Array<{ sessionId: string; command: string }> = [];
return {
calls,
deps: {
bridge: {
async aiExec(sessionId: string, command: string) {
calls.push({ sessionId, command });
return { ok: true, stdout: 'ok', stderr: '', exitCode: 0 };
},
},
context: {
sessions: [{
sessionId: 'ssh-ps',
hostId: 'host-1',
hostname: 'windows.example',
label: 'Windows',
protocol: 'ssh',
shellType,
connected: true,
}],
},
commandBlocklist: DEFAULT_COMMAND_BLOCKLIST,
permissionMode: 'auto' as const,
},
};
}
test('unknown remote shell defers shell-specific defaults to the live bridge', async () => {
const { deps, calls } = createDeps();
const command = 'Write-Host "now: $(Get-Date)"';
const result = await executeTerminalExecute(deps, { sessionId: 'ssh-ps', command });
assert.equal(result.ok, true);
assert.deepEqual(calls, [{ sessionId: 'ssh-ps', command }]);
});
test('known POSIX shell still blocks command substitution before IPC', async () => {
const { deps, calls } = createDeps('posix');
const result = await executeTerminalExecute(deps, {
sessionId: 'ssh-ps',
command: 'echo $(whoami)',
});
assert.equal(result.ok, false);
assert.equal(calls.length, 0);
});

View File

@@ -0,0 +1,249 @@
/**
* Shared tool execution logic used by both the Catty Agent executor (switch/case)
* and the Vercel AI SDK tool wrappers.
*
* Each function encapsulates the core business logic for a tool — validation,
* safety checks, bridge calls, and result formatting — so callers only need to
* adapt the return value to their own response shape.
*/
import type { NetcattyBridge, ExecutorContext } from '../cattyAgent/executor';
import type { AIPermissionMode, WebSearchConfig } from '../types';
import { checkCommandSafety, checkCommandSafetyCommonOnly } from '../cattyAgent/safety';
import { executeWebSearchProvider } from './webSearchProviders';
// ---------------------------------------------------------------------------
// Shared result types
// ---------------------------------------------------------------------------
/** Discriminated union returned by every shared executor. */
export type ToolExecResult<T = unknown> =
| { ok: true; data: T }
| { ok: false; error: string; data?: T };
// ---------------------------------------------------------------------------
// Dependencies bundle
// ---------------------------------------------------------------------------
export interface ToolDeps {
bridge: NetcattyBridge;
context: ExecutorContext | (() => ExecutorContext);
commandBlocklist?: string[];
permissionMode: AIPermissionMode;
webSearchConfig?: WebSearchConfig;
chatSessionId?: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function resolveContext(ctx: ToolDeps['context']): ExecutorContext {
return typeof ctx === 'function' ? ctx() : ctx;
}
function validSessionIds(ctx: ToolDeps['context']): Set<string> {
const resolved = resolveContext(ctx);
return new Set(resolved.sessions.map(s => s.sessionId));
}
function validateSessionScope(ctx: ToolDeps['context'], sessionId: string): string | null {
const ids = validSessionIds(ctx);
if (!ids.has(sessionId)) {
return `Session "${sessionId}" is not in the current scope. Available sessions: ${[...ids].join(', ')}`;
}
return null;
}
function isObserver(mode: AIPermissionMode): boolean {
return mode === 'observer';
}
// ---------------------------------------------------------------------------
// Tool executors
// ---------------------------------------------------------------------------
export async function executeTerminalExecute(
deps: ToolDeps,
args: { sessionId: string; command: string },
): Promise<ToolExecResult<{ stdout: string; stderr: string; exitCode: number | null }>> {
const { bridge, context, commandBlocklist, permissionMode } = deps;
const { sessionId, command } = args;
if (!sessionId || !command) {
return { ok: false, error: 'Missing sessionId or command' };
}
const scopeErr = validateSessionScope(context, sessionId);
if (scopeErr) return { ok: false, error: scopeErr };
if (isObserver(permissionMode)) {
return { ok: false, error: 'Observer mode: command execution is disabled. Switch to Confirm or Auto mode to execute commands.' };
}
// Shell blocklist is meaningless on network device CLIs (e.g. "shutdown"
// disables an interface on Cisco). Skip for serial and network device sessions.
// The bridge layer (handleExec / netcatty:ai:exec) also has its own session-aware check.
const resolved = resolveContext(context);
const targetSession = resolved.sessions.find(s => s.sessionId === sessionId);
const proto = targetSession?.protocol || '';
const isSshOrSerial = proto === 'ssh' || proto === 'serial';
const isNetworkDevice = proto === 'serial' || (targetSession?.deviceType === 'network' && isSshOrSerial);
if (!isNetworkDevice) {
// Remote renderer metadata often has no shell type until the main/worker
// process probes the live session. Pre-filter user/common rules here and
// defer shell-specific defaults to that authoritative live check.
const safety = targetSession?.shellType
? checkCommandSafety(command, commandBlocklist, targetSession.shellType)
: checkCommandSafetyCommonOnly(command, commandBlocklist);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Matched pattern: ${safety.matchedPattern}` };
}
}
const result = await bridge.aiExec(sessionId, command, deps.chatSessionId);
// Real execution failures (timeout, disconnect, no stream) have an `error` field
if (!result.ok && result.error) {
return {
ok: false,
error: result.error,
data: {
stdout: result.stdout || '',
stderr: result.stderr || '',
exitCode: isNetworkDevice ? (result.exitCode ?? null) : (result.exitCode ?? -1),
},
};
}
// Command ran (even if exit code is non-zero) — always return stdout+exitCode for LLM to judge.
// Network device / serial sessions return exitCode: null because vendor CLIs don't expose
// exit codes. Preserve null so the model knows exit status is unavailable rather than
// seeing a misleading 0 (success) or -1 (failure).
return {
ok: true,
data: {
stdout: result.stdout || '',
stderr: result.stderr || '',
exitCode: isNetworkDevice ? (result.exitCode ?? null) : (result.exitCode ?? -1),
},
};
}
export function executeWorkspaceGetInfo(
deps: ToolDeps,
): ToolExecResult<{
workspaceId: string | null;
workspaceName: string | null;
sessions: Array<{
sessionId: string;
hostname: string;
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
}>;
}> {
const context = resolveContext(deps.context);
return {
ok: true,
data: {
workspaceId: context.workspaceId || null,
workspaceName: context.workspaceName || null,
sessions: context.sessions.map(s => ({
sessionId: s.sessionId,
hostname: s.hostname,
label: s.label,
os: s.os,
username: s.username,
protocol: s.protocol,
shellType: s.shellType,
deviceType: s.deviceType,
connected: s.connected,
})),
},
};
}
export function executeWorkspaceGetSessionInfo(
deps: ToolDeps,
args: { sessionId: string },
): ToolExecResult<ExecutorContext['sessions'][number]> {
const context = resolveContext(deps.context);
const session = context.sessions.find(s => s.sessionId === args.sessionId);
if (!session) {
return { ok: false, error: `Session not found: ${args.sessionId}` };
}
return { ok: true, data: session };
}
// ---------------------------------------------------------------------------
// Web Search & URL Fetch (read-only, no permission check needed)
// ---------------------------------------------------------------------------
export async function executeWebSearch(
deps: ToolDeps,
args: { query: string; maxResults?: number },
): Promise<ToolExecResult<{ results: Array<{ title: string; url: string; content: string }> }>> {
const { bridge, webSearchConfig } = deps;
if (!webSearchConfig?.enabled) {
return { ok: false, error: 'Web search is not enabled. Please configure a search provider in Settings → AI.' };
}
if (!args.query) {
return { ok: false, error: 'Missing search query' };
}
try {
const maxResults = Math.max(1, Math.min(20, args.maxResults ?? webSearchConfig.maxResults ?? 5));
const results = await executeWebSearchProvider(bridge, webSearchConfig, args.query, maxResults);
// Enforce maxResults after provider normalization (some providers ignore the limit)
return { ok: true, data: { results: results.slice(0, maxResults) } };
} catch (err) {
return { ok: false, error: `Web search failed: ${err instanceof Error ? err.message : String(err)}` };
}
}
interface BridgeFetchResponse {
ok: boolean;
status?: number;
data?: string;
error?: string;
}
export async function executeUrlFetch(
deps: ToolDeps,
args: { url: string; maxLength?: number },
): Promise<ToolExecResult<{ url: string; content: string; status: number }>> {
const { bridge } = deps;
const { url } = args;
if (!url || !url.startsWith('https://')) {
return { ok: false, error: 'Invalid URL. Must start with https://' };
}
const aiFetch = (bridge as unknown as Record<string, (...a: unknown[]) => Promise<unknown>>).aiFetch;
if (!aiFetch) {
return { ok: false, error: 'aiFetch is not available on the bridge' };
}
try {
// skipHostCheck=true, followRedirects=true: url_fetch targets user-provided URLs
const resp = await aiFetch(url, 'GET', {
'User-Agent': 'Netcatty-AI/1.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7',
}, undefined, undefined, true, true) as BridgeFetchResponse;
if (!resp.ok) {
return { ok: false, error: resp.error || `HTTP ${resp.status}` };
}
const maxLength = Math.max(1, Math.min(200000, args.maxLength ?? 50000));
let content = resp.data || '';
if (content.length > maxLength) {
content = content.slice(0, maxLength) + '\n\n[Content truncated]';
}
return { ok: true, data: { url, content, status: resp.status || 200 } };
} catch (err) {
return { ok: false, error: `URL fetch failed: ${err instanceof Error ? err.message : String(err)}` };
}
}

View File

@@ -0,0 +1,214 @@
/**
* Web search provider implementations.
*
* Each provider function normalises its API response into a common
* `{ results: Array<{ title, url, content }> }` shape so callers don't need
* to know about provider-specific quirks.
*
* All HTTP requests go through `bridge.aiFetch()` to avoid CORS issues in the
* renderer process.
*/
import type { NetcattyBridge } from '../cattyAgent/executor';
import type { WebSearchConfig } from '../types';
import { WEB_SEARCH_PROVIDER_PRESETS } from '../types';
export interface WebSearchResult {
title: string;
url: string;
content: string;
}
interface BridgeFetchResponse {
ok: boolean;
status?: number;
data?: string;
error?: string;
}
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
function resolveApiHost(config: WebSearchConfig): string {
return config.apiHost || WEB_SEARCH_PROVIDER_PRESETS[config.providerId].defaultApiHost;
}
async function fetchJson(
bridge: NetcattyBridge,
url: string,
method: string,
headers: Record<string, string>,
body?: string,
): Promise<unknown> {
const aiFetch = (bridge as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).aiFetch;
if (!aiFetch) throw new Error('aiFetch is not available on the bridge');
// Search API hosts are added to the allowlist via aiSyncWebSearch, no skipHostCheck needed
const resp = await aiFetch(url, method, headers, body) as BridgeFetchResponse;
if (!resp.ok) throw new Error(resp.error || `HTTP ${resp.status}`);
return JSON.parse(resp.data || '{}');
}
// ---------------------------------------------------------------------------
// Tavily
// ---------------------------------------------------------------------------
async function searchTavily(
bridge: NetcattyBridge,
config: WebSearchConfig,
query: string,
maxResults: number,
): Promise<WebSearchResult[]> {
const host = resolveApiHost(config);
const data = await fetchJson(bridge, `${host}/search`, 'POST', {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
}, JSON.stringify({
query,
max_results: maxResults,
search_depth: 'basic',
})) as { results?: Array<{ title?: string; url?: string; content?: string }> };
return (data.results || []).map(r => ({
title: r.title || '',
url: r.url || '',
content: r.content || '',
}));
}
// ---------------------------------------------------------------------------
// Exa
// ---------------------------------------------------------------------------
async function searchExa(
bridge: NetcattyBridge,
config: WebSearchConfig,
query: string,
maxResults: number,
): Promise<WebSearchResult[]> {
const host = resolveApiHost(config);
const data = await fetchJson(bridge, `${host}/search`, 'POST', {
'Content-Type': 'application/json',
'x-api-key': config.apiKey || '',
}, JSON.stringify({
query,
numResults: maxResults,
contents: { text: true },
})) as { results?: Array<{ title?: string; url?: string; text?: string }> };
return (data.results || []).map(r => ({
title: r.title || '',
url: r.url || '',
content: r.text || '',
}));
}
// ---------------------------------------------------------------------------
// Bocha
// ---------------------------------------------------------------------------
async function searchBocha(
bridge: NetcattyBridge,
config: WebSearchConfig,
query: string,
maxResults: number,
): Promise<WebSearchResult[]> {
const host = resolveApiHost(config);
const data = await fetchJson(bridge, `${host}/v1/web-search`, 'POST', {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
}, JSON.stringify({
query,
count: maxResults,
summary: true,
})) as { webPages?: { value?: Array<{ name?: string; url?: string; snippet?: string; summary?: string }> } };
return (data.webPages?.value || []).map(r => ({
title: r.name || '',
url: r.url || '',
content: r.summary || r.snippet || '',
}));
}
// ---------------------------------------------------------------------------
// Zhipu
// ---------------------------------------------------------------------------
async function searchZhipu(
bridge: NetcattyBridge,
config: WebSearchConfig,
query: string,
_maxResults: number,
): Promise<WebSearchResult[]> {
const host = resolveApiHost(config);
const data = await fetchJson(bridge, `${host}/web_search`, 'POST', {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
}, JSON.stringify({
search_query: query,
search_engine: 'search_std',
})) as { search_result?: Array<{ title?: string; link?: string; content?: string }> };
return (data.search_result || []).map(r => ({
title: r.title || '',
url: r.link || '',
content: r.content || '',
}));
}
// ---------------------------------------------------------------------------
// SearXNG
// ---------------------------------------------------------------------------
async function searchSearxng(
bridge: NetcattyBridge,
config: WebSearchConfig,
query: string,
_maxResults: number,
): Promise<WebSearchResult[]> {
const host = resolveApiHost(config);
if (!host) throw new Error('SearXNG requires an API Host to be configured');
const url = `${host}/search?q=${encodeURIComponent(query)}&format=json`;
const data = await fetchJson(bridge, url, 'GET', {}) as {
results?: Array<{ title?: string; url?: string; content?: string }>;
};
return (data.results || []).map(r => ({
title: r.title || '',
url: r.url || '',
content: r.content || '',
}));
}
// ---------------------------------------------------------------------------
// Dispatcher
// ---------------------------------------------------------------------------
const PROVIDER_SEARCH_FNS: Record<string, typeof searchTavily> = {
tavily: searchTavily,
exa: searchExa,
bocha: searchBocha,
zhipu: searchZhipu,
searxng: searchSearxng,
};
/**
* Placeholder token for the web search API key.
* The renderer sends this in HTTP headers; the main process replaces it
* with the real decrypted key before the request is sent, so plaintext
* keys never enter the renderer.
*/
const WEB_SEARCH_KEY_PLACEHOLDER = '__WEB_SEARCH_KEY__';
export async function executeWebSearchProvider(
bridge: NetcattyBridge,
config: WebSearchConfig,
query: string,
maxResults: number,
): Promise<WebSearchResult[]> {
const fn = PROVIDER_SEARCH_FNS[config.providerId];
if (!fn) throw new Error(`Unsupported web search provider: ${config.providerId}`);
// Use placeholder — main process replaces with real decrypted key before HTTP request
const safeConfig = { ...config, apiKey: WEB_SEARCH_KEY_PLACEHOLDER };
return fn(bridge, safeConfig, query, maxResults);
}