Skip to content

Commit aad75a4

Browse files
committed
fix: expand codeKeys and increase fallback truncation in native popup
1 parent 4526c7b commit aad75a4

File tree

1 file changed

+123
-172
lines changed

1 file changed

+123
-172
lines changed

src/ui/native.ts

Lines changed: 123 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// src/ui/native.ts
2-
import { spawn } from 'child_process';
2+
import { spawn, ChildProcess } from 'child_process'; // 1. Added ChildProcess import
3+
import chalk from 'chalk';
34

45
const isTestEnv = () => {
56
return (
@@ -13,122 +14,124 @@ const isTestEnv = () => {
1314
};
1415

1516
/**
16-
* Sends a non-blocking, one-way system notification.
17+
* Truncates long strings by keeping the start and end.
1718
*/
19+
function smartTruncate(str: string, maxLen: number = 500): string {
20+
if (str.length <= maxLen) return str;
21+
const edge = Math.floor(maxLen / 2) - 3;
22+
return `${str.slice(0, edge)} ... ${str.slice(-edge)}`;
23+
}
24+
25+
function formatArgs(args: unknown): string {
26+
if (args === null || args === undefined) return '(none)';
27+
28+
let parsed = args;
29+
30+
// 1. EXTRA STEP: If args is a string, try to see if it's nested JSON
31+
// Gemini often wraps the command inside a stringified JSON object
32+
if (typeof args === 'string') {
33+
const trimmed = args.trim();
34+
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
35+
try {
36+
parsed = JSON.parse(trimmed);
37+
} catch {
38+
parsed = args;
39+
}
40+
} else {
41+
return smartTruncate(args, 600);
42+
}
43+
}
44+
45+
// 2. Now handle the object (whether it was passed as one or parsed above)
46+
if (typeof parsed === 'object' && !Array.isArray(parsed)) {
47+
const obj = parsed as Record<string, unknown>;
48+
49+
const codeKeys = [
50+
'command',
51+
'cmd',
52+
'shell_command',
53+
'bash_command',
54+
'script',
55+
'code',
56+
'input',
57+
'sql',
58+
'query',
59+
'arguments',
60+
'args',
61+
'param',
62+
'params',
63+
'text',
64+
];
65+
const foundKey = Object.keys(obj).find((k) => codeKeys.includes(k.toLowerCase()));
66+
67+
if (foundKey) {
68+
const val = obj[foundKey];
69+
const str = typeof val === 'string' ? val : JSON.stringify(val);
70+
// Visual improvement: add a label so you know what you are looking at
71+
return `[${foundKey.toUpperCase()}]:\n${smartTruncate(str, 500)}`;
72+
}
73+
74+
return Object.entries(obj)
75+
.slice(0, 5)
76+
.map(
77+
([k, v]) => ` ${k}: ${smartTruncate(typeof v === 'string' ? v : JSON.stringify(v), 300)}`
78+
)
79+
.join('\n');
80+
}
81+
82+
return smartTruncate(JSON.stringify(parsed), 200);
83+
}
84+
1885
export function sendDesktopNotification(title: string, body: string): void {
1986
if (isTestEnv()) return;
20-
2187
try {
22-
const safeTitle = title.replace(/"/g, '\\"');
23-
const safeBody = body.replace(/"/g, '\\"');
24-
2588
if (process.platform === 'darwin') {
26-
const script = `display notification "${safeBody}" with title "${safeTitle}"`;
89+
const script = `display notification "${body.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`;
2790
spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref();
2891
} else if (process.platform === 'linux') {
29-
spawn('notify-send', [safeTitle, safeBody, '--icon=dialog-warning'], {
92+
spawn('notify-send', [title, body, '--icon=dialog-warning'], {
3093
detached: true,
3194
stdio: 'ignore',
3295
}).unref();
3396
}
3497
} catch {
35-
/* Silent fail for notifications */
36-
}
37-
}
38-
39-
/**
40-
* Formats tool arguments into readable key: value lines.
41-
* Each value is truncated to avoid overwhelming the popup.
42-
*/
43-
function formatArgs(args: unknown): string {
44-
if (args === null || args === undefined) return '(none)';
45-
46-
if (typeof args !== 'object' || Array.isArray(args)) {
47-
const str = typeof args === 'string' ? args : JSON.stringify(args);
48-
return str.length > 200 ? str.slice(0, 200) + '…' : str;
49-
}
50-
51-
const entries = Object.entries(args as Record<string, unknown>).filter(
52-
([, v]) => v !== null && v !== undefined && v !== ''
53-
);
54-
55-
if (entries.length === 0) return '(none)';
56-
57-
const MAX_FIELDS = 5;
58-
const MAX_VALUE_LEN = 120;
59-
60-
const lines = entries.slice(0, MAX_FIELDS).map(([key, val]) => {
61-
const str = typeof val === 'string' ? val : JSON.stringify(val);
62-
const truncated = str.length > MAX_VALUE_LEN ? str.slice(0, MAX_VALUE_LEN) + '…' : str;
63-
return ` ${key}: ${truncated}`;
64-
});
65-
66-
if (entries.length > MAX_FIELDS) {
67-
lines.push(` … and ${entries.length - MAX_FIELDS} more field(s)`);
98+
/* ignore */
6899
}
69-
70-
return lines.join('\n');
71100
}
72101

73-
/**
74-
* Triggers an asynchronous, two-way OS dialog box.
75-
* Returns: 'allow' | 'deny' | 'always_allow'
76-
*/
77102
export async function askNativePopup(
78103
toolName: string,
79104
args: unknown,
80105
agent?: string,
81106
explainableLabel?: string,
82-
locked: boolean = false, // Phase 4.1: The Remote Lock
83-
signal?: AbortSignal // Phase 4.2: The Auto-Close Trigger
107+
locked: boolean = false,
108+
signal?: AbortSignal
84109
): Promise<'allow' | 'deny' | 'always_allow'> {
85110
if (isTestEnv()) return 'deny';
86-
if (process.env.NODE9_DEBUG === '1' || process.env.VITEST) {
87-
console.log(`[DEBUG Native] askNativePopup called for: ${toolName}`);
88-
console.log(`[DEBUG Native] isTestEnv check:`, {
89-
VITEST: process.env.VITEST,
90-
NODE_ENV: process.env.NODE_ENV,
91-
CI: process.env.CI,
92-
isTest: isTestEnv(),
93-
});
94-
}
95111

96-
const title = locked
97-
? `⚡ Node9 — Locked by Admin Policy`
98-
: `🛡️ Node9 — Action Requires Approval`;
112+
const formattedArgs = formatArgs(args);
113+
const title = locked ? `⚡ Node9 — Locked` : `🛡️ Node9 — Action Approval`;
99114

100-
// Build a structured, scannable message
101115
let message = '';
116+
if (locked) message += `⚠️ LOCKED BY ADMIN POLICY\n`;
117+
message += `Tool: ${toolName}\n`;
118+
message += `Agent: ${agent || 'AI Agent'}\n`;
119+
message += `Rule: ${explainableLabel || 'Security Policy'}\n\n`;
120+
message += `${formattedArgs}`;
102121

103-
if (locked) {
104-
message += `⚡ Awaiting remote approval via Slack. Local override is disabled.\n`;
105-
message += `─────────────────────────────────\n`;
106-
}
107-
108-
message += `Tool: ${toolName}\n`;
109-
message += `Agent: ${agent || 'AI Agent'}\n`;
110-
if (explainableLabel) {
111-
message += `Reason: ${explainableLabel}\n`;
112-
}
113-
message += `\nArguments:\n${formatArgs(args)}`;
114-
115-
if (!locked) {
116-
message += `\n\nEnter = Allow | Click "Block" to deny`;
117-
}
118-
119-
// Escape for shell/applescript safety
120-
const safeMessage = message.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/`/g, "'");
121-
const safeTitle = title.replace(/"/g, '\\"');
122+
process.stderr.write(chalk.yellow(`\n🛡️ Node9: Intercepted "${toolName}" — awaiting user...\n`));
122123

123124
return new Promise((resolve) => {
124-
let childProcess: ReturnType<typeof spawn> | null = null;
125+
// 2. FIXED: Use ChildProcess type instead of any
126+
let childProcess: ChildProcess | null = null;
125127

126-
// The Auto-Close Logic (Fires when Cloud wins the race)
127128
const onAbort = () => {
128-
if (childProcess) {
129+
if (childProcess && childProcess.pid) {
129130
try {
130-
process.kill(childProcess.pid!, 'SIGKILL');
131-
} catch {}
131+
process.kill(childProcess.pid, 'SIGKILL');
132+
} catch {
133+
/* ignore */
134+
}
132135
}
133136
resolve('deny');
134137
};
@@ -138,103 +141,51 @@ export async function askNativePopup(
138141
signal.addEventListener('abort', onAbort);
139142
}
140143

141-
const cleanup = () => {
142-
if (signal) signal.removeEventListener('abort', onAbort);
143-
};
144-
145144
try {
146-
// --- macOS ---
147145
if (process.platform === 'darwin') {
148-
// Default button is "Allow" — Enter = permit, Escape = Block
149146
const buttons = locked
150147
? `buttons {"Waiting…"} default button "Waiting…"`
151148
: `buttons {"Block", "Always Allow", "Allow"} default button "Allow" cancel button "Block"`;
152-
153-
const script = `
154-
tell application "System Events"
155-
activate
156-
display dialog "${safeMessage}" with title "${safeTitle}" ${buttons}
157-
end tell`;
158-
159-
childProcess = spawn('osascript', ['-e', script]);
160-
let output = '';
161-
childProcess.stdout?.on('data', (d) => (output += d.toString()));
162-
163-
childProcess.on('close', (code) => {
164-
cleanup();
165-
if (locked) return resolve('deny');
166-
if (code === 0) {
167-
if (output.includes('Always Allow')) return resolve('always_allow');
168-
if (output.includes('Allow')) return resolve('allow');
169-
}
170-
resolve('deny');
171-
});
172-
}
173-
174-
// --- Linux ---
175-
else if (process.platform === 'linux') {
176-
const argsList = locked
177-
? [
178-
'--info',
179-
'--title',
180-
title,
181-
'--text',
182-
safeMessage,
183-
'--ok-label',
184-
'Waiting for Slack…',
185-
'--timeout',
186-
'300',
187-
]
188-
: [
189-
'--question',
190-
'--title',
191-
title,
192-
'--text',
193-
safeMessage,
194-
'--ok-label',
195-
'Allow',
196-
'--cancel-label',
197-
'Block',
198-
'--extra-button',
199-
'Always Allow',
200-
'--timeout',
201-
'300',
202-
];
203-
149+
const script = `on run argv\ntell application "System Events"\nactivate\ndisplay dialog (item 1 of argv) with title (item 2 of argv) ${buttons}\nend tell\nend run`;
150+
childProcess = spawn('osascript', ['-e', script, '--', message, title]);
151+
} else if (process.platform === 'linux') {
152+
const argsList = [
153+
locked ? '--info' : '--question',
154+
'--modal',
155+
'--width=450',
156+
'--title',
157+
title,
158+
'--text',
159+
message,
160+
'--ok-label',
161+
locked ? 'Waiting...' : 'Allow',
162+
'--timeout',
163+
'300',
164+
];
165+
if (!locked) {
166+
argsList.push('--cancel-label', 'Block');
167+
argsList.push('--extra-button', 'Always Allow');
168+
}
204169
childProcess = spawn('zenity', argsList);
205-
let output = '';
206-
childProcess.stdout?.on('data', (d) => (output += d.toString()));
207-
208-
childProcess.on('close', (code) => {
209-
cleanup();
210-
if (locked) return resolve('deny');
211-
// zenity: --ok-label (Allow) = exit 0, --cancel-label (Block) = exit 1, extra-button = stdout
212-
if (output.trim() === 'Always Allow') return resolve('always_allow');
213-
if (code === 0) return resolve('allow'); // clicked "Allow" (ok-label, Enter)
214-
resolve('deny'); // clicked "Block" or timed out
215-
});
170+
} else if (process.platform === 'win32') {
171+
const b64Msg = Buffer.from(message).toString('base64');
172+
const b64Title = Buffer.from(title).toString('base64');
173+
const ps = `Add-Type -AssemblyName PresentationFramework; $msg = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${b64Msg}")); $title = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${b64Title}")); $res = [System.Windows.MessageBox]::Show($msg, $title, "${locked ? 'OK' : 'YesNo'}", "Warning", "Button2", "DefaultDesktopOnly"); if ($res -eq "Yes") { exit 0 } else { exit 1 }`;
174+
childProcess = spawn('powershell', ['-Command', ps]);
216175
}
217176

218-
// --- Windows ---
219-
else if (process.platform === 'win32') {
220-
const buttonType = locked ? 'OK' : 'YesNo';
221-
const ps = `
222-
Add-Type -AssemblyName PresentationFramework;
223-
$res = [System.Windows.MessageBox]::Show("${safeMessage}", "${safeTitle}", "${buttonType}", "Warning", "Button2", "DefaultDesktopOnly");
224-
if ($res -eq "Yes") { exit 0 } else { exit 1 }`;
177+
let output = '';
178+
// 3. FIXED: Specified Buffer type for stream data
179+
childProcess?.stdout?.on('data', (d: Buffer) => (output += d.toString()));
225180

226-
childProcess = spawn('powershell', ['-Command', ps]);
227-
childProcess.on('close', (code) => {
228-
cleanup();
229-
if (locked) return resolve('deny');
230-
resolve(code === 0 ? 'allow' : 'deny');
231-
});
232-
} else {
233-
cleanup();
181+
childProcess?.on('close', (code: number) => {
182+
if (signal) signal.removeEventListener('abort', onAbort);
183+
if (locked) return resolve('deny');
184+
if (output.includes('Always Allow')) return resolve('always_allow');
185+
if (code === 0) return resolve('allow');
234186
resolve('deny');
235-
}
187+
});
236188
} catch {
237-
cleanup();
238189
resolve('deny');
239190
}
240191
});

0 commit comments

Comments
 (0)