[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
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:
35
infrastructure/ai/shared/approvalConstants.ts
Normal file
35
infrastructure/ai/shared/approvalConstants.ts
Normal 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 };
|
||||
}
|
||||
303
infrastructure/ai/shared/approvalGate.timeout.test.ts
Normal file
303
infrastructure/ai/shared/approvalGate.timeout.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
528
infrastructure/ai/shared/approvalGate.ts
Normal file
528
infrastructure/ai/shared/approvalGate.ts
Normal 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?.();
|
||||
};
|
||||
}
|
||||
29
infrastructure/ai/shared/bashArity.test.ts
Normal file
29
infrastructure/ai/shared/bashArity.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
163
infrastructure/ai/shared/bashArity.ts
Normal file
163
infrastructure/ai/shared/bashArity.ts
Normal 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); }
|
||||
112
infrastructure/ai/shared/codebuddyElicitations.test.ts
Normal file
112
infrastructure/ai/shared/codebuddyElicitations.test.ts
Normal 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();
|
||||
});
|
||||
102
infrastructure/ai/shared/codebuddyElicitations.ts
Normal file
102
infrastructure/ai/shared/codebuddyElicitations.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
150
infrastructure/ai/shared/codexAppServerInteractions.test.ts
Normal file
150
infrastructure/ai/shared/codexAppServerInteractions.test.ts
Normal 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 });
|
||||
});
|
||||
198
infrastructure/ai/shared/codexAppServerInteractions.ts
Normal file
198
infrastructure/ai/shared/codexAppServerInteractions.ts
Normal 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 });
|
||||
}
|
||||
12
infrastructure/ai/shared/pathLikeCommand.ts
Normal file
12
infrastructure/ai/shared/pathLikeCommand.ts
Normal 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();
|
||||
}
|
||||
127
infrastructure/ai/shared/sessionExecutionQueue.ts
Normal file
127
infrastructure/ai/shared/sessionExecutionQueue.ts
Normal 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();
|
||||
}
|
||||
146
infrastructure/ai/shared/shellCommandGrant.test.ts
Normal file
146
infrastructure/ai/shared/shellCommandGrant.test.ts
Normal 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 *'],
|
||||
);
|
||||
});
|
||||
});
|
||||
495
infrastructure/ai/shared/shellCommandGrant.ts
Normal file
495
infrastructure/ai/shared/shellCommandGrant.ts
Normal 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];
|
||||
}
|
||||
50
infrastructure/ai/shared/streamStateErrors.ts
Normal file
50
infrastructure/ai/shared/streamStateErrors.ts
Normal 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;
|
||||
}
|
||||
52
infrastructure/ai/shared/toolExecutors.commandSafety.test.ts
Normal file
52
infrastructure/ai/shared/toolExecutors.commandSafety.test.ts
Normal 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);
|
||||
});
|
||||
249
infrastructure/ai/shared/toolExecutors.ts
Normal file
249
infrastructure/ai/shared/toolExecutors.ts
Normal 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)}` };
|
||||
}
|
||||
}
|
||||
214
infrastructure/ai/shared/webSearchProviders.ts
Normal file
214
infrastructure/ai/shared/webSearchProviders.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user