Skip to content

Commit f2e70ee

Browse files
bugs fix
1 parent f8de540 commit f2e70ee

14 files changed

Lines changed: 1025 additions & 214 deletions

File tree

src/main/ai.ts

Lines changed: 317 additions & 61 deletions
Large diffs are not rendered by default.

src/main/db.ts

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,20 @@ function migrateFromJson(db: Database.Database): void {
166166
settings?: Record<string, unknown>
167167
}
168168

169-
// Wrap all inserts in a single transaction so a crash mid-migration
169+
// Pre-read credentials.json before transaction block to avoid synchronous FS I/O inside SQLite transaction
170+
let parsedCredentials: Record<string, string> | undefined = undefined
171+
const credPath = path.join(path.dirname(jsonPath), 'credentials.json')
172+
if (existsSync(credPath)) {
173+
try {
174+
const credRaw = readFileSync(credPath, 'utf-8')
175+
const credData = JSON.parse(credRaw) as { credentials?: Record<string, string> }
176+
parsedCredentials = credData.credentials
177+
} catch (e) {
178+
console.error('[db] Failed to read credentials.json during migration prep:', e)
179+
}
180+
}
181+
182+
// Wrap all inserts and the completion marker in a single transaction so a crash mid-migration
170183
// rolls back everything and migrated_v1 stays unset → retry on next launch
171184
const migrate = db.transaction(() => {
172185
const insertGroup = db.prepare(`
@@ -211,25 +224,23 @@ function migrateFromJson(db: Database.Database): void {
211224
insertSetting.run({ key, value: JSON.stringify(value) })
212225
}
213226

214-
// Migrate credentials.json — same folder as the config.json we found
215-
const credPath = path.join(path.dirname(jsonPath), 'credentials.json')
216-
if (existsSync(credPath)) {
217-
try {
218-
const credRaw = readFileSync(credPath, 'utf-8')
219-
const credData = JSON.parse(credRaw) as { credentials?: Record<string, string> }
220-
const insertCred = db.prepare(
221-
"INSERT OR IGNORE INTO settings (key, value) VALUES (@key, @value)"
222-
)
223-
for (const [k, v] of Object.entries(credData.credentials ?? {})) {
224-
if (safeStorage.isEncryptionAvailable()) {
225-
const encrypted = safeStorage.encryptString(v)
226-
insertCred.run({ key: `cred:${k}`, value: JSON.stringify(encrypted.toString('base64')) })
227-
} else {
228-
insertCred.run({ key: `cred:${k}`, value: JSON.stringify(v) })
229-
}
227+
// Migrate parsed credentials inside the transaction
228+
if (parsedCredentials) {
229+
const insertCred = db.prepare(
230+
"INSERT OR IGNORE INTO settings (key, value) VALUES (@key, @value)"
231+
)
232+
for (const [k, v] of Object.entries(parsedCredentials)) {
233+
if (safeStorage.isEncryptionAvailable()) {
234+
const encrypted = safeStorage.encryptString(v)
235+
insertCred.run({ key: `cred:${k}`, value: JSON.stringify(encrypted.toString('base64')) })
236+
} else {
237+
insertCred.run({ key: `cred:${k}`, value: JSON.stringify(v) })
230238
}
231-
} catch {/* ignore credential migration errors */}
239+
}
232240
}
241+
242+
// Include the completion marker inside the transaction for absolute atomicity
243+
db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('migrated_v1', 'true')").run()
233244
})
234245

235246
migrate()
@@ -239,10 +250,10 @@ function migrateFromJson(db: Database.Database): void {
239250
// Do NOT set migrated_v1 on failure — allow retry on next launch
240251
return
241252
}
253+
} else {
254+
// Mark migration complete if no config.json exists to migrate
255+
db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('migrated_v1', 'true')").run()
242256
}
243-
244-
// Mark migration complete only after a successful (or skipped) migration
245-
db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('migrated_v1', 'true')").run()
246257
}
247258

248259
// ── Row ↔ Domain object helpers ───────────────────────────────────────────────

src/main/ssh.ts

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,29 @@ const activeForwards = new Map<string, ForwardServer>() // key = forwardId
4343
// ── SOCKS proxy handler ───────────────────────────────────────────────────────
4444

4545
function handleSocksConnection(sock: net.Socket, sshClient: Client): void {
46-
sock.on('error', () => sock.destroy())
46+
let cleaned = false
47+
const cleanup = () => {
48+
if (cleaned) return
49+
cleaned = true
50+
sock.removeListener('data', onGreeting)
51+
sock.destroy()
52+
}
53+
54+
sock.on('error', cleanup)
55+
sock.on('close', cleanup)
4756

4857
let buf = Buffer.alloc(0)
4958

5059
const onGreeting = (chunk: Buffer) => {
5160
buf = Buffer.concat([buf, chunk])
61+
if (buf.length > 1024) {
62+
cleanup()
63+
return
64+
}
5265
if (buf.length < 2) return
5366
sock.removeListener('data', onGreeting)
67+
sock.off('error', cleanup)
68+
sock.off('close', cleanup)
5469

5570
if (buf[0] === 0x04) {
5671
handleSocks4(sock, sshClient, buf)
@@ -67,10 +82,25 @@ function handleSocksConnection(sock: net.Socket, sshClient: Client): void {
6782
}
6883

6984
function handleSocks5Request(sock: net.Socket, sshClient: Client): void {
85+
let cleaned = false
86+
const cleanup = () => {
87+
if (cleaned) return
88+
cleaned = true
89+
sock.removeListener('data', onRequest)
90+
sock.destroy()
91+
}
92+
93+
sock.on('error', cleanup)
94+
sock.on('close', cleanup)
95+
7096
let buf = Buffer.alloc(0)
7197

7298
const onRequest = (chunk: Buffer) => {
7399
buf = Buffer.concat([buf, chunk])
100+
if (buf.length > 1024) {
101+
cleanup()
102+
return
103+
}
74104
if (buf.length < 4) return
75105

76106
if (buf[0] !== 0x05 || buf[1] !== 0x01) {
@@ -110,6 +140,8 @@ function handleSocks5Request(sock: net.Socket, sshClient: Client): void {
110140
}
111141

112142
sock.removeListener('data', onRequest)
143+
sock.off('error', cleanup)
144+
sock.off('close', cleanup)
113145
const remaining = buf.slice(end)
114146

115147
sshClient.forwardOut('127.0.0.1', 0, host, port, (err, stream) => {
@@ -120,10 +152,22 @@ function handleSocks5Request(sock: net.Socket, sshClient: Client): void {
120152
}
121153
sock.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]))
122154
if (remaining.length > 0) stream.write(remaining)
155+
156+
let closed = false
157+
const cleanupPipe = () => {
158+
if (closed) return
159+
closed = true
160+
sock.destroy()
161+
stream.destroy()
162+
}
163+
123164
sock.pipe(stream)
124165
stream.pipe(sock)
125-
stream.on('close', () => sock.destroy())
126-
sock.on('close', () => stream.destroy())
166+
167+
sock.on('close', cleanupPipe)
168+
sock.on('error', cleanupPipe)
169+
stream.on('close', cleanupPipe)
170+
stream.on('error', cleanupPipe)
127171
})
128172
}
129173

@@ -146,10 +190,22 @@ function handleSocks4(sock: net.Socket, sshClient: Client, buf: Buffer): void {
146190
return
147191
}
148192
sock.write(Buffer.from([0x00, 0x5a, 0, 0, 0, 0, 0, 0]))
193+
194+
let closed = false
195+
const cleanupPipe = () => {
196+
if (closed) return
197+
closed = true
198+
sock.destroy()
199+
stream.destroy()
200+
}
201+
149202
sock.pipe(stream)
150203
stream.pipe(sock)
151-
stream.on('close', () => sock.destroy())
152-
sock.on('close', () => stream.destroy())
204+
205+
sock.on('close', cleanupPipe)
206+
sock.on('error', cleanupPipe)
207+
stream.on('close', cleanupPipe)
208+
stream.on('error', cleanupPipe)
153209
})
154210
}
155211

src/preload/index.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ const onSerialClosed = makeFanout<[string]>('serial:closed')
2424
const onSerialError = makeFanout<[string, string]>('serial:error')
2525
const onSftpProgress = makeFanout<[string, string, number, number]>('sftp:progress')
2626
const onSftpClosed = makeFanout<[string]>('sftp:closed')
27-
const onAiChunk = makeFanout<[string]>('ai:chunk')
28-
const onAiDone = makeFanout<[{ inputTokens: number; outputTokens: number } | undefined]>('ai:done')
29-
const onAiToolCall = makeFanout<[{ id: string; command: string; reason: string; targetSession?: string }]>('ai:tool-call')
30-
const onAiError = makeFanout<[string]>('ai:error')
31-
const onAiPlan = makeFanout<[{ objective: string; steps: string[] }]>('ai:plan')
27+
const onAiChunk = makeFanout<[{ sessionId: string; text: string }]>('ai:chunk')
28+
const onAiDone = makeFanout<[{ sessionId: string; inputTokens?: number; outputTokens?: number }]>('ai:done')
29+
const onAiToolCall = makeFanout<[{ sessionId: string; id: string; command: string; reason: string; targetSession?: string; policyBlock?: string }]>('ai:tool-call')
30+
const onAiError = makeFanout<[{ sessionId: string; message: string }]>('ai:error')
31+
const onAiPlan = makeFanout<[{ sessionId: string; objective: string; steps: string[] }]>('ai:plan')
3232
const onWindowMaximized = makeFanout<[boolean]>('window:maximized-change')
3333
const onUpdaterAvailable = makeFanout<[{ version: string; releaseDate: string; releaseNotes: string | null }]>('updater:update-available')
3434
const onUpdaterError = makeFanout<[string]>('updater:error')
@@ -196,7 +196,7 @@ const api = {
196196
// AI Copilot
197197
ai: {
198198
chat: (payload: unknown) => ipcRenderer.invoke('ai:chat', payload),
199-
cancel: () => ipcRenderer.send('ai:cancel'),
199+
cancel: (sessionId?: string) => ipcRenderer.send('ai:cancel', sessionId),
200200
toolResult: (callId: string, output: string) => ipcRenderer.invoke('ai:tool-result', callId, output),
201201
resetBlacklist: () => ipcRenderer.invoke('ai:reset-blacklist'),
202202
exportMarkdown: (payload: unknown) => ipcRenderer.invoke('ai:export-markdown', payload),

src/renderer/src/App.tsx

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export default function App(): JSX.Element {
2323
licenseValid,
2424
sidebarCollapsed, sidebarWidth, setSidebarCollapsed,
2525
selectedConnectionIds, clearSelection,
26+
keybindingSettings,
2627
} = useAppStore()
2728
const [masterLocked, setMasterLocked] = useState<boolean | null>(null)
2829
const [locked, setLocked] = useState(false)
@@ -92,18 +93,34 @@ export default function App(): JSX.Element {
9293
}
9394
}, [])
9495

96+
const matchKeybinding = (e: KeyboardEvent, binding: string) => {
97+
if (!binding) return false
98+
const parts = binding.split('+')
99+
const key = parts[parts.length - 1].toLowerCase()
100+
const requiresMod = parts.includes('Mod') || parts.includes('CmdOrCtrl') || parts.includes('Ctrl') || parts.includes('Cmd')
101+
const requiresShift = parts.includes('Shift')
102+
const requiresAlt = parts.includes('Alt')
103+
104+
const hasMod = e.metaKey || e.ctrlKey
105+
const hasShift = e.shiftKey
106+
const hasAlt = e.altKey
107+
108+
return e.key.toLowerCase() === key &&
109+
requiresMod === hasMod &&
110+
requiresShift === hasShift &&
111+
requiresAlt === hasAlt
112+
}
113+
95114
const handleKeyDown = useCallback(
96115
(e: KeyboardEvent) => {
97-
const mod = e.metaKey || e.ctrlKey
98-
99116
// Escape — clear multi-select
100117
if (e.key === 'Escape' && selectedConnectionIds.size > 0) {
101118
clearSelection()
102119
return
103120
}
104121

105122
// ? — Help dialog (only when not typing in an input)
106-
if (e.key === '?' && !mod) {
123+
if (e.key === '?' && !e.metaKey && !e.ctrlKey && !e.altKey) {
107124
const tag = (e.target as HTMLElement).tagName
108125
if (tag !== 'INPUT' && tag !== 'TEXTAREA') {
109126
setHelpTab('shortcuts')
@@ -112,33 +129,22 @@ export default function App(): JSX.Element {
112129
return
113130
}
114131

115-
if (!mod) return
116-
117-
// ⌘K / Ctrl+K — Quick Connect
118-
if (e.key === 'k') {
132+
if (matchKeybinding(e, keybindingSettings.quickConnect)) {
119133
e.preventDefault()
120134
setQuickConnectOpen(true)
121-
}
122-
// ⌘, / Ctrl+, — Settings
123-
else if (e.key === ',') {
135+
} else if (matchKeybinding(e, keybindingSettings.settings)) {
124136
e.preventDefault()
125137
setSettingsOpen(true)
126-
}
127-
// ⌘T / Ctrl+T — New tab (Quick Connect)
128-
else if (e.key === 't') {
138+
} else if (matchKeybinding(e, keybindingSettings.newTab)) {
129139
e.preventDefault()
130140
setQuickConnectOpen(true)
131-
}
132-
// ⌘W / Ctrl+W — Close active tab
133-
else if (e.key === 'w') {
141+
} else if (matchKeybinding(e, keybindingSettings.closeTab)) {
134142
e.preventDefault()
135143
if (activeSessionId) {
136144
if (activeSessionId === splitSessionId) setSplitSession(null)
137145
closeSession(activeSessionId)
138146
}
139-
}
140-
// ⌘Shift+A / Ctrl+Shift+A — Toggle ARIA panel
141-
else if (e.shiftKey && e.key === 'A') {
147+
} else if (matchKeybinding(e, keybindingSettings.toggleAi)) {
142148
e.preventDefault()
143149
if (!aiPanelOpen && !licenseValid) {
144150
toast.error('License key required', {
@@ -148,24 +154,18 @@ export default function App(): JSX.Element {
148154
return
149155
}
150156
setAiPanelOpen(!aiPanelOpen)
151-
}
152-
// ⌘D / Ctrl+D — Toggle split view
153-
else if (e.key === 'd') {
157+
} else if (matchKeybinding(e, keybindingSettings.toggleSplit)) {
154158
e.preventDefault()
155159
if (splitSessionId) {
156160
setSplitSession(null)
157161
} else if (sessions.length >= 2) {
158162
const other = sessions.find(s => s.id !== activeSessionId)
159163
if (other) setSplitSession(other.id)
160164
}
161-
}
162-
// ⌘B / Ctrl+B — Toggle sidebar
163-
else if (e.key === 'b') {
165+
} else if (matchKeybinding(e, keybindingSettings.toggleSidebar)) {
164166
e.preventDefault()
165167
setSidebarCollapsed(!sidebarCollapsed)
166-
}
167-
// ⌘1-9 / Ctrl+1-9 — Switch to tab N
168-
else if (e.key >= '1' && e.key <= '9') {
168+
} else if ((e.metaKey || e.ctrlKey) && e.key >= '1' && e.key <= '9') {
169169
const idx = parseInt(e.key) - 1
170170
if (idx < sessions.length) {
171171
e.preventDefault()
@@ -179,6 +179,7 @@ export default function App(): JSX.Element {
179179
closeSession, setSplitSession, setActiveSession,
180180
sidebarCollapsed, setSidebarCollapsed,
181181
selectedConnectionIds, clearSelection,
182+
keybindingSettings,
182183
] )
183184

184185
useEffect(() => {

src/renderer/src/components/ai/AiCommandBlock.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState } from 'react'
22
import { Terminal, CheckCircle2, XCircle, Loader2, ShieldAlert, Play } from 'lucide-react'
3-
import { cn } from '../../lib/utils'
3+
import { cn, isCommandBlacklisted } from '../../lib/utils'
44
import { AiToolCall } from '../../store'
55
import { useAppStore } from '../../store'
66

@@ -17,9 +17,7 @@ export function AiCommandBlock({ call, approval, blacklist, onApprove, onBlock }
1717
const [expanded, setExpanded] = useState(false)
1818
const sessions = useAppStore(s => s.sessions)
1919

20-
const isBlacklisted = blacklist.some((pattern) =>
21-
call.command.toLowerCase().includes(pattern.toLowerCase())
22-
)
20+
const isBlacklisted = isCommandBlacklisted(call.command, blacklist)
2321

2422
// Find target session name for multi-session display
2523
const targetSessionName = call.targetSession
@@ -53,6 +51,14 @@ export function AiCommandBlock({ call, approval, blacklist, onApprove, onBlock }
5351
{call.reason}
5452
</div>
5553

54+
{/* Server-side policy rejection — already blocked by main process */}
55+
{call.policyBlock && (
56+
<div className="flex items-start gap-2 px-3 py-2 border-t border-red-500/20 bg-red-500/5 text-red-400">
57+
<ShieldAlert className="w-3 h-3 shrink-0 mt-0.5" />
58+
<span className="text-[11px] leading-relaxed">{call.policyBlock}</span>
59+
</div>
60+
)}
61+
5662
{/* Action buttons — only when pending */}
5763
{call.status === 'pending' && (
5864
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/40">

0 commit comments

Comments
 (0)