Skip to content

Commit 2b7288c

Browse files
v0.8.7
# v0.8.7 — Localization, Bedrock Fixes & API Token Refresh ## Features - **Hungarian, German, and Polish translations** — Three new languages added with full coverage of UI strings across the shell, session info popover, files section, rich text input, and island components. Polish includes proper plural form support. - **API token refresh endpoint** — API-type sources can now specify an optional `renewEndpoint` for automatic token refresh without requiring full OAuth. Useful for APIs that issue short-lived tokens with a dedicated renewal mechanism. ## Improvements - **Raw body support for API sources** — API source proxy now correctly handles `_rawBody` and `_contentType` parameters, enabling sources that need to send non-JSON payloads (e.g., form-encoded, XML). ## Bug Fixes - **Bedrock model defaults and region awareness** — Corrected the default model list for Bedrock connections and added region-aware inference profile resolution, fixing issues where initial model selections failed with "model identifier is invalid" errors. Bare Claude model IDs without the required `us.` inference profile prefix are now filtered out automatically. (Fixes #536, partially addresses #528) - **Model dropdown scrolling** — Dropdown sub-menus (e.g., model selector) are now scrollable when the list exceeds the viewport height. (Fixes #527) - **Workspace file access** — Files under the workspace working directory can now be opened correctly, fixing "cannot open code file" errors. (Fixes #526) - **Server lock released on quit** — The server lock file is now properly released when the app quits, with hardened stale lock detection to prevent "another instance running" false positives on crash recovery. - **Stale reconnect session refresh** — Moved the stale reconnect session refresh into a transactional atom action, preventing race conditions that could cause message loss after sleep/wake reconnection. ## Breaking Changes - None.
1 parent 3caafb7 commit 2b7288c

63 files changed

Lines changed: 4888 additions & 323 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@craft-agent/cli",
3-
"version": "0.8.6",
3+
"version": "0.8.7",
44
"license": "Apache-2.0",
55
"description": "Terminal client for Craft Agent server",
66
"type": "module",

apps/electron/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@craft-agent/electron",
3-
"version": "0.8.6",
3+
"version": "0.8.7",
44
"description": "Electron desktop app for Craft Agents",
55
"main": "dist/main.cjs",
66
"private": true,

apps/electron/resources/docs/sources.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,20 @@ With environment variables:
400400

401401
REST APIs become flexible tools that Claude can call.
402402

403+
**Request bodies:** By default, `params` is JSON-serialized for POST/PUT/PATCH requests. For endpoints that expect non-JSON bodies (plain text, XML, form data, etc.), use the special `_rawBody` and `_contentType` params:
404+
405+
```json
406+
{
407+
"params": {
408+
"_rawBody": "raw string content to send as-is",
409+
"_contentType": "text/plain"
410+
}
411+
}
412+
```
413+
414+
- `_rawBody` (string) — sent as the request body without JSON encoding
415+
- `_contentType` (string, optional) — sets the Content-Type header (defaults to `text/plain`)
416+
403417
**IMPORTANT:** Authenticated API sources require a `testEndpoint` to validate credentials during `source_test`. Without it, we cannot verify your credentials work.
404418

405419
**Header authentication (X-API-Key style):**
@@ -615,6 +629,61 @@ The `testEndpoint` specifies which endpoint to call when validating credentials:
615629

616630
**Public APIs (authType: 'none')** don't require testEndpoint - we test by hitting the base URL.
617631

632+
### renewEndpoint Configuration (Optional)
633+
634+
The optional `renewEndpoint` enables automatic token renewal for non-OAuth bearer-token APIs. When the token expires, the system calls this endpoint to get a fresh token — no manual re-authentication needed.
635+
636+
```json
637+
{
638+
"api": {
639+
"baseUrl": "https://api.example.com/",
640+
"authType": "bearer",
641+
"renewEndpoint": {
642+
"path": "auth/refresh",
643+
"method": "POST",
644+
"tokenField": "access_token",
645+
"expiresInField": "expires_in"
646+
}
647+
}
648+
}
649+
```
650+
651+
**Fields:**
652+
653+
| Field | Required | Default | Description |
654+
|-------|----------|---------|-------------|
655+
| `path` | Yes || Renew URL — relative path (resolved against `baseUrl`) or absolute URL |
656+
| `method` | No | `"POST"` | HTTP method: `"GET"` or `"POST"` |
657+
| `body` | No || Request body (JSON). Use `{{token}}` as placeholder for the current access token |
658+
| `headers` | No || Extra headers. Use `{{token}}` as placeholder. Merged on top of `defaultHeaders` |
659+
| `tokenField` | No | `"access_token"` | JSON field name for the new token in the response |
660+
| `expiresInField` | No | `"expires_in"` | JSON field name for expiry in seconds in the response |
661+
| `fallbackTtlSecs` | No || Fallback TTL in seconds when the response doesn't include expiry info |
662+
663+
**How it works:**
664+
1. Before each API request, the system checks if the token is expired or expiring soon (within 5 minutes)
665+
2. If so, it calls the `renewEndpoint` with the current token in the Authorization header
666+
3. The new token and expiry are extracted from the response and stored
667+
4. The refreshed token is used for the API request
668+
669+
**Token substitution:** Use `{{token}}` in `body` or `headers` to insert the current access token. This supports nested objects — all string values are scanned recursively.
670+
671+
**Example with token in request body:**
672+
```json
673+
{
674+
"renewEndpoint": {
675+
"path": "auth/refresh",
676+
"body": { "current_token": "{{token}}" },
677+
"tokenField": "new_token",
678+
"expiresInField": "ttl"
679+
}
680+
}
681+
```
682+
683+
**When `body` is omitted**, the current token is sent via the standard Authorization header (using the source's `authScheme`).
684+
685+
**Note:** This is for APIs with their own token renewal mechanism, not OAuth. For OAuth-based APIs, use `authType: "oauth"` instead.
686+
618687
### Local Sources
619688

620689
Filesystem access for local folders.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# v0.8.7 — Localization, Bedrock Fixes & API Token Refresh
2+
3+
## Features
4+
- **Hungarian, German, and Polish translations** — Three new languages added with full coverage of UI strings across the shell, session info popover, files section, rich text input, and island components. Polish includes proper plural form support.
5+
- **API token refresh endpoint** — API-type sources can now specify an optional `renewEndpoint` for automatic token refresh without requiring full OAuth. Useful for APIs that issue short-lived tokens with a dedicated renewal mechanism.
6+
7+
## Improvements
8+
- **Raw body support for API sources** — API source proxy now correctly handles `_rawBody` and `_contentType` parameters, enabling sources that need to send non-JSON payloads (e.g., form-encoded, XML).
9+
10+
## Bug Fixes
11+
- **Bedrock model defaults and region awareness** — Corrected the default model list for Bedrock connections and added region-aware inference profile resolution, fixing issues where initial model selections failed with "model identifier is invalid" errors. Bare Claude model IDs without the required `us.` inference profile prefix are now filtered out automatically. (Fixes #536, partially addresses #528)
12+
- **Model dropdown scrolling** — Dropdown sub-menus (e.g., model selector) are now scrollable when the list exceeds the viewport height. (Fixes #527)
13+
- **Workspace file access** — Files under the workspace working directory can now be opened correctly, fixing "cannot open code file" errors. (Fixes #526)
14+
- **Server lock released on quit** — The server lock file is now properly released when the app quits, with hardened stale lock detection to prevent "another instance running" false positives on crash recovery.
15+
- **Stale reconnect session refresh** — Moved the stale reconnect session refresh into a transactional atom action, preventing race conditions that could cause message loss after sleep/wake reconnection.
16+
17+
## Breaking Changes
18+
- None.

apps/electron/src/main/browser-pane-manager.ts

Lines changed: 5 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@
66
* shared session/cookie partition and CDP automation support.
77
*/
88

9-
import { join, parse as parsePath, normalize, isAbsolute, sep } from 'path'
9+
import { join, parse as parsePath } from 'path'
1010
import { existsSync, mkdirSync } from 'fs'
11-
import { realpath } from 'fs/promises'
12-
import { homedir, tmpdir } from 'os'
11+
import { validateFilePath, getWorkspaceAllowedDirs } from '@craft-agent/server-core/handlers'
1312
import { BrowserView, BrowserWindow, app, ipcMain, nativeTheme, session, shell, type Session as ElectronSession } from 'electron'
1413
import { mainLog } from './logger'
1514
import type { WindowManager } from './window-manager'
@@ -1534,60 +1533,15 @@ export class BrowserPaneManager implements IBrowserPaneManager {
15341533
return instance.downloads.slice(-limit)
15351534
}
15361535

1537-
private async validateUploadFilePath(filePath: string): Promise<string> {
1538-
let normalizedPath = normalize(filePath)
1539-
1540-
if (normalizedPath.startsWith('~')) {
1541-
normalizedPath = normalizedPath.replace(/^~/, homedir())
1542-
}
1543-
1544-
if (!isAbsolute(normalizedPath)) {
1545-
throw new Error(`Upload path must be absolute: ${filePath}`)
1546-
}
1547-
1548-
let realFilePath: string
1549-
try {
1550-
realFilePath = await realpath(normalizedPath)
1551-
} catch {
1552-
realFilePath = normalizedPath
1553-
}
1554-
1555-
const allowedDirs = [homedir(), tmpdir()]
1556-
const isAllowed = allowedDirs.some((dir) => {
1557-
const normalizedDir = normalize(dir)
1558-
const normalizedReal = normalize(realFilePath)
1559-
return normalizedReal.startsWith(normalizedDir + sep) || normalizedReal === normalizedDir
1560-
})
1561-
1562-
if (!isAllowed) {
1563-
throw new Error(`Access denied for upload path (outside allowed directories): ${filePath}`)
1564-
}
1565-
1566-
const sensitivePatterns = [
1567-
/\.ssh\//,
1568-
/\.gnupg\//,
1569-
/\.aws\/credentials/,
1570-
/\.env$/,
1571-
/\.env\./,
1572-
/credentials\.json$/,
1573-
/secrets?\./i,
1574-
/\.pem$/,
1575-
/\.key$/,
1576-
]
1577-
1578-
if (sensitivePatterns.some((pattern) => pattern.test(realFilePath))) {
1579-
throw new Error(`Access denied for upload path (sensitive file): ${filePath}`)
1580-
}
1581-
1582-
return realFilePath
1583-
}
1536+
// validateUploadFilePath removed — uses shared validateFilePath from @craft-agent/server-core/handlers
15841537

15851538
async uploadFile(id: string, ref: string, filePaths: string[]): Promise<ElementGeometry> {
15861539
const instance = this.requireAliveInstance(id)
15871540

15881541
const safePaths: string[] = []
15891542
for (const p of filePaths) {
1590-
const safePath = await this.validateUploadFilePath(p)
1543+
const workspaceId = this.resolveLaunchWorkspaceId()
1544+
const safePath = await validateFilePath(p, getWorkspaceAllowedDirs(workspaceId))
15911545
if (!existsSync(safePath)) throw new Error(`File not found: ${p}`)
15921546
safePaths.push(safePath)
15931547
}

apps/electron/src/main/handlers/system.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { execSync } from 'child_process'
55
import { RPC_CHANNELS } from '@craft-agent/shared/protocol'
66
import { getGitBashPath, setGitBashPath, clearGitBashPath } from '@craft-agent/shared/config'
77
import { isUsableGitBashPath, validateGitBashPath } from '@craft-agent/server-core/services'
8-
import { validateFilePath } from '@craft-agent/server-core/handlers'
8+
import { validateFilePath, getWorkspaceAllowedDirs } from '@craft-agent/server-core/handlers'
99
import type { RpcServer } from '@craft-agent/server-core/transport'
1010
import type { HandlerDeps } from './handler-deps'
1111
import {
@@ -236,8 +236,10 @@ export function registerSystemCoreHandlers(server: RpcServer, deps: HandlerDeps)
236236

237237
server.handle(RPC_CHANNELS.shell.OPEN_FILE, async (ctx, path: string) => {
238238
try {
239-
const absolutePath = resolve(path)
240-
const safePath = await validateFilePath(absolutePath)
239+
const expanded = path.startsWith('~') ? path.replace(/^~/, homedir()) : path
240+
const absolutePath = resolve(expanded)
241+
const workspaceId = ctx.workspaceId ?? deps.windowManager?.getWorkspaceForWindow(ctx.webContentsId!)
242+
const safePath = await validateFilePath(absolutePath, getWorkspaceAllowedDirs(workspaceId))
241243
const result = await requestClientOpenPath(server, ctx.clientId, safePath)
242244
if (result.error) throw new Error(result.error)
243245
} catch (error) {
@@ -249,8 +251,10 @@ export function registerSystemCoreHandlers(server: RpcServer, deps: HandlerDeps)
249251

250252
server.handle(RPC_CHANNELS.shell.SHOW_IN_FOLDER, async (ctx, path: string) => {
251253
try {
252-
const absolutePath = resolve(path)
253-
const safePath = await validateFilePath(absolutePath)
254+
const expanded = path.startsWith('~') ? path.replace(/^~/, homedir()) : path
255+
const absolutePath = resolve(expanded)
256+
const workspaceId = ctx.workspaceId ?? deps.windowManager?.getWorkspaceForWindow(ctx.webContentsId!)
257+
const safePath = await validateFilePath(absolutePath, getWorkspaceAllowedDirs(workspaceId))
254258
await requestClientShowInFolder(server, ctx.clientId, safePath)
255259
} catch (error) {
256260
const message = error instanceof Error ? error.message : 'Unknown error'

apps/electron/src/main/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ import { registerCoreRpcHandlers, cleanupSessionFileWatchForClient } from '@craf
7676
import type { PlatformServices } from '../runtime/platform'
7777
import { createElectronPlatform } from './platform'
7878
import type { HandlerDeps } from './handlers/handler-deps'
79-
import { bootstrapServer } from '@craft-agent/server-core/bootstrap'
79+
import { bootstrapServer, releaseServerLock } from '@craft-agent/server-core/bootstrap'
8080
import { initModelRefreshService, getModelRefreshService, setFetcherPlatform } from '@craft-agent/server-core/model-fetchers'
8181
import { setSearchPlatform, setImageProcessor } from '@craft-agent/server-core/services'
8282
import { createApplicationMenu } from './menu'
@@ -1084,6 +1084,10 @@ app.on('before-quit', async (event) => {
10841084
const { cleanup: cleanupPowerManager } = await import('./power-manager')
10851085
cleanupPowerManager()
10861086

1087+
// Release the server lock file so the next launch doesn't see a stale PID.
1088+
// This must happen regardless of the exit path (normal quit or update quit).
1089+
releaseServerLock()
1090+
10871091
// If update is in progress, let electron-updater handle the quit flow
10881092
// Force exit breaks the NSIS installer on Windows
10891093
if (isUpdating()) {

apps/electron/src/renderer/App.tsx

Lines changed: 9 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
addSessionAtom,
3838
removeSessionAtom,
3939
updateSessionAtom,
40+
refreshSessionsMetadataAtom,
4041
sessionAtomFamily,
4142
sessionMetaMapAtom,
4243
sessionIdsAtom,
@@ -483,63 +484,26 @@ export default function App() {
483484
const refreshSessionListMetadataFromServer = useCallback(async (): Promise<Map<string, SessionMeta> | null> => {
484485
try {
485486
const sessions = await window.electronAPI.getSessions()
487+
console.info(`[App] getSessions returned ${sessions.length} session(s) for reconnect refresh`)
486488
const loadedSessionIds = store.get(loadedSessionsAtom)
487-
const currentIds = store.get(sessionIdsAtom)
488-
const latestIds = new Set(sessions.map(session => session.id))
489489

490-
for (const staleSessionId of currentIds) {
491-
if (!latestIds.has(staleSessionId)) {
492-
removeSession(staleSessionId)
493-
}
494-
}
490+
// Single transactional atom write — all cross-atom mutations happen
491+
// inside one Jotai write function so React subscribers see one
492+
// consistent update instead of intermediate states.
493+
const nextMetaMap = store.set(refreshSessionsMetadataAtom, { sessions, loadedSessionIds })
495494

496-
const unloadedIds: string[] = []
495+
// Sync app-level state (React hooks / non-atom concerns) after the atom transaction
497496
for (const session of sessions) {
498-
const currentSession = store.get(sessionAtomFamily(session.id))
499-
const shouldPreserveMessages = !!currentSession && loadedSessionIds.has(session.id)
500-
const nextSession = shouldPreserveMessages && currentSession
501-
? {
502-
...session,
503-
messages: currentSession.messages,
504-
}
505-
: session
506-
507-
store.set(sessionAtomFamily(session.id), nextSession)
508-
509-
// Track sessions written without messages so lazy-loading re-fetches them
510-
if (!shouldPreserveMessages && loadedSessionIds.has(session.id)) {
511-
unloadedIds.push(session.id)
512-
}
513-
514497
syncSessionOptionsFromSession(session)
515-
void reconcilePermissionModeState(session.id)
516498
}
517-
518-
// Remove from loadedSessionsAtom so ensureSessionMessagesLoaded will re-fetch
519-
if (unloadedIds.length > 0) {
520-
const nextLoaded = new Set(store.get(loadedSessionsAtom))
521-
for (const id of unloadedIds) nextLoaded.delete(id)
522-
store.set(loadedSessionsAtom, nextLoaded)
523-
}
524-
525-
const nextMetaMap = new Map<string, SessionMeta>()
526-
for (const session of sessions) {
527-
nextMetaMap.set(session.id, extractSessionMeta(session))
528-
}
529-
store.set(sessionMetaMapAtom, nextMetaMap)
530-
531-
const nextIds = sessions
532-
.slice()
533-
.sort((a, b) => (b.lastMessageAt || 0) - (a.lastMessageAt || 0))
534-
.map(session => session.id)
535-
store.set(sessionIdsAtom, nextIds)
499+
await Promise.allSettled(sessions.map(s => reconcilePermissionModeState(s.id)))
536500

537501
return nextMetaMap
538502
} catch (err) {
539503
console.error('[App] Failed to refresh session list metadata after reconnect:', err)
540504
return null
541505
}
542-
}, [store, removeSession, syncSessionOptionsFromSession, reconcilePermissionModeState])
506+
}, [store, syncSessionOptionsFromSession, reconcilePermissionModeState])
543507

544508
// Stale session watchdog — catches stuck sessions that the reconnect protocol misses
545509
const { trackSessionActivity } = useStaleSessionRecovery({

0 commit comments

Comments
 (0)