diff --git a/CLAUDE.md b/CLAUDE.md index 653d6b4..8046a8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,9 +73,10 @@ docs/ - **Crash-safety** — every HTTP route dispatch is wrapped in try/catch → 500 (one bad session never takes down the server); `findSessionFile` looks up its index with `Object.prototype.hasOwnProperty.call(...)` to avoid prototype-pollution DoS; delete / bulk-delete validate `SAFE_SESSION_ID` - **Desktop app is a thin shell** — `desktop/main.js` spawns the *unmodified* server as a Node child and points a `BrowserWindow` at it. Keep the server desktop-agnostic; desktop-only capabilities are exposed through `preload.js` (`window.codbashDesktop`) and detected at runtime in the frontend (e.g. the native folder picker is only wired up when `window.codbashDesktop.pickFolder` exists) - **View-aware chrome** — `render()` stamps `document.body` with `data-view`; the session toolbar is hidden in Overview/Workspace via `body[data-view="workspace"|"overview"] .toolbar { display:none }` -- **Running-agents sidebar tree** (Workspace) is built from `activeSessions` grouped by real `cwd` and labeled by agent — do NOT reconstruct it from static project config +- **Running-agents sidebar tree** (Workspace) is built from `activeSessions` grouped by real `cwd` and labeled by agent — do NOT reconstruct it from static project config. It lists agents in **external native terminals** only: `getActiveSessions()` tags each with `local` (true = descends from a codbash browser-pty pane, false = external), and the tree shows `!local` — codbash's own panes are already visible as tabs. Clicking a row raises that real terminal window via `POST /api/focus` (`focusTerminalByPid`); it must NEVER spawn a blank in-app terminal (an empty shell isn't the agent, and `claude --continue` on a live agent would fork a second instance). A still-running agent's PTY cannot be mirrored/attached from the browser terminal — focus the real window instead. See `docs/design/running-agents-external.md`. - **Saved layouts round-trip the full pane** — `sanitizePane` preserves `cmd` + `prefill` + `cwd` (not just `cmd`); dropping any of these silently loses the user's launch command on restore - **No `window.prompt` in Electron** — use `codbashPrompt()` (app.js) for any text input; the native prompt is a no-op in the desktop shell +- **Two update paths, mutually exclusive** — the npm CLI self-updates via `POST /api/update` (`npm i -g codbash-app@latest` + restart). The **desktop app updates in-place via `electron-updater`** (download-on-click → restart, driven by the frontend banner over `window.codbashDesktop.updater` IPC and `main.js`). `desktop/main.js` sets `CODBASH_DESKTOP=1` so the server **refuses `/api/update` (400)** — running `npm i -g` inside the signed, read-only app bundle would update an unrelated global copy and the restart would land back on the bundled old version. macOS in-place update needs the **`.zip` target + `latest-mac.yml`** (Squirrel.Mac can't apply a DMG) and a signed build; on failure the banner falls back to opening the releases page (`codbash:open-releases`). See `desktop/RELEASE.md` §4. ## API routes diff --git a/desktop/RELEASE.md b/desktop/RELEASE.md index 4603283..77677dd 100644 --- a/desktop/RELEASE.md +++ b/desktop/RELEASE.md @@ -61,6 +61,10 @@ env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY -u https_proxy -u http_proxy -u al - Pass `--arm64 --x64` explicitly. Passing a target on the CLI (`… dmg`) **overrides** the `arch` array in `package.json`, so `npm run dist:mac` builds only the host arch. +- The `mac.target` array now builds **both `dmg` and `zip`** for each arch. The + DMG is the first-install download; the **`.zip` is what electron-updater + installs from** (Squirrel.Mac can't apply a DMG). `latest-mac.yml` must + reference the zips, so ship the zips + their blockmaps in the Release too. - The signing identity is pinned in `package.json` → `build.mac.identity` as `"Valeriy Kovalsky (A933C2TJXU)"` — **without** the `Developer ID Application:` prefix (electron-builder rejects the prefix). @@ -69,24 +73,37 @@ env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY -u https_proxy -u http_proxy -u al since electron-builder builds the DMG after the hook runs. **2b. Notarize + staple the DMG containers, then regenerate the update feed** -(the staple mutates the DMG, so blockmaps + `latest-mac.yml` must be recomputed): +(the staple mutates the DMG bytes, so any changed artifact's checksum/blockmap in +`latest-mac.yml` must be recomputed): ```bash for dmg in dist/codbash--arm64.dmg dist/codbash-.dmg; do xcrun notarytool submit "$dmg" --keychain-profile codbash-notary --wait xcrun stapler staple "$dmg" - ./node_modules/app-builder-bin/mac/app-builder_arm64 blockmap \ - --input "$dmg" --output "$dmg.blockmap" # prints the {size,sha512} for latest-mac.yml done -# then rewrite dist/latest-mac.yml with the new size+sha512 for both DMGs. +npm run refresh-update-feed # scripts/regenerate-latest-mac.js ``` -Publish: +`refresh-update-feed` parses electron-builder's own `dist/latest-mac.yml` and +refreshes `sha512`/`size`/`blockMapSize` only for entries whose bytes actually +changed on disk (detected by sha512 mismatch), then re-syncs the top-level +`sha512` to the `path` file. It's schema-preserving (never hand-writes the feed) +and idempotent. Since electron-updater's mac feed points at the untouched +`.zip`, the `.zip` entries are left as-is and only stapled DMG entries (if the +feed lists them) get recomputed. Pure transforms are covered by +`test/desktop-update-feed.test.js`; **validate against the real feed on the first +signed build** (electron-updater fails loudly on a checksum mismatch). + +Publish (include the **zips + their blockmaps** — electron-updater installs from +the zip, and `latest-mac.yml` points at it; a Release with only the DMGs makes +in-app update fail with a 404 for the zip): ```bash gh release create v --title "codbash (macOS desktop)" --target main \ - dist/codbash--arm64.dmg dist/codbash--arm64.dmg.blockmap \ - dist/codbash-.dmg dist/codbash-.dmg.blockmap \ + dist/codbash--arm64.dmg dist/codbash--arm64.dmg.blockmap \ + dist/codbash-.dmg dist/codbash-.dmg.blockmap \ + dist/codbash--arm64-mac.zip dist/codbash--arm64-mac.zip.blockmap \ + dist/codbash--mac.zip dist/codbash--mac.zip.blockmap \ dist/latest-mac.yml ``` @@ -109,15 +126,52 @@ hdiutil attach /tmp/q.dmg -nobrowse; spctl -a -vvv -t exec "/Volumes/codbash .exe (+ .blockmap) and dist/latest.yml +gh release upload v \ + "dist/codbash Setup .exe" "dist/codbash Setup .exe.blockmap" \ + dist/latest.yml +``` + +> The `nsis` target (not `portable`) is required for electron-updater. +> +> **⚠️ Security — do not ship Windows *auto-update* to real users unsigned.** +> electron-updater's `verifyUpdateCodeSignature` compares the running app's +> Authenticode publisher against the downloaded installer's; with no Windows +> code-signing cert on either side that check is a no-op, leaving only the +> `sha512` in `latest.yml` (which comes from the same release pipeline an +> attacker would compromise). An unsigned *in-place auto-updater* is strictly +> worse than a manual download-and-run, because it removes the last human +> checkpoint. Until an OV/EV cert is in place, keep Windows on the notify-only +> fallback (open the releases page) rather than enabling silent download+install. +> `allowDowngrade` and `allowPrerelease` are pinned `false` in `main.js` so a +> mistagged or rolled-back release can't reach stable users regardless. ## CI note diff --git a/desktop/main.js b/desktop/main.js index b272059..87b9b40 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -10,7 +10,6 @@ const { app, BrowserWindow, shell, dialog, Menu, ipcMain } = require('electron'); const { spawn } = require('child_process'); const http = require('http'); -const https = require('https'); const net = require('net'); const path = require('path'); const fs = require('fs'); @@ -111,7 +110,11 @@ function startServer(port) { const entry = resolveServerEntry(); const nodeBin = resolveNodeBin(); serverProc = spawn(nodeBin, [entry, 'run', '--port=' + port, '--host=127.0.0.1', '--no-browser'], { - env: Object.assign({}, process.env, { CODEDASH_HOST: '127.0.0.1' }), + // CODBASH_DESKTOP=1 tells the server it runs inside the Electron shell, so the + // web self-update route (`POST /api/update` → `npm i -g`) refuses: it would + // update an unrelated npm-global copy while the app keeps running its bundled + // server. In the desktop app, updates go through electron-updater (below). + env: Object.assign({}, process.env, { CODEDASH_HOST: '127.0.0.1', CODBASH_DESKTOP: '1' }), stdio: ['ignore', 'pipe', 'pipe'], }); serverProc.stdout.on('data', function (d) { process.stdout.write('[codbash] ' + d); }); @@ -163,6 +166,66 @@ function registerIpc() { // The renderer decides a shortcut had no in-page meaning (e.g. Cmd+W outside // the Workspace) and asks us to close the window instead. ipcMain.on('codbash:close-window', function () { if (win) { try { win.close(); } catch (_e) {} } }); + + // ── In-app updater (electron-updater) ────────────────────────────────────── + // The renderer's update banner drives these. autoDownload is off, so the flow + // is: check → 'available' → user clicks Download → downloadUpdate() → progress + // → 'downloaded' → user clicks Restart → quitAndInstall(). See initAutoUpdater. + // + // Defense in depth, because these calls are powerful (force-download + + // quitAndInstall = force-relaunch of the whole app): + // 1. isTrustedSender — only honor calls from a frame served by our own local + // server, so a page the window somehow navigated to can't drive updates. + // 2. _updateState gating — download only when an update is 'available', + // install only once it's 'downloaded'. The renderer's button visibility is + // a UX convenience, NOT the security boundary; the main process enforces + // the sequence so an out-of-order/forged call can't force a relaunch. + ipcMain.handle('codbash:update-check', function (event) { + if (!isTrustedSender(event)) return { error: 'forbidden' }; + if (!getAutoUpdater()) return { unavailable: true }; + // Won't clobber an in-flight/downloaded update (see maybeCheckForUpdates). + return maybeCheckForUpdates().then(function () { return { ok: true }; }); + }); + ipcMain.handle('codbash:update-download', function (event) { + if (!isTrustedSender(event)) return { error: 'forbidden' }; + if (_updateState !== 'available') return { error: 'no update available' }; + if (_downloadInFlight) return { ok: true, already: true }; // second click before first progress + const u = getAutoUpdater(); + if (!u) return { unavailable: true }; + _downloadInFlight = true; // set synchronously so a fast double-click can't double-download + return u.downloadUpdate().then(function () { return { ok: true }; }) + .catch(function (e) { _downloadInFlight = false; return { error: String((e && e.message) || e) }; }); + }); + ipcMain.handle('codbash:update-install', function (event) { + if (!isTrustedSender(event)) return { error: 'forbidden' }; + if (_updateState !== 'downloaded') return { error: 'update not downloaded' }; + const u = getAutoUpdater(); + if (!u) return { unavailable: true }; + // Defer so the IPC reply is sent before the app tears down. before-quit sets + // app.isQuitting and kills the server child, so its exit handler stays quiet. + setImmediate(function () { try { u.quitAndInstall(); } catch (_e) {} }); + return { ok: true }; + }); + // Fallback when in-place update can't apply (e.g. unsigned build): open the + // GitHub releases page so the user can still grab the installer manually. + ipcMain.on('codbash:open-releases', function (event) { + if (!isTrustedSender(event)) return; + shell.openExternal('https://github.com/vakovalskii/codbash/releases/latest') + .catch(function (e) { process.stderr.write('[desktop] openExternal failed: ' + ((e && e.message) || e) + '\n'); }); + }); +} + +// Only trust IPC from a frame our own local server actually served. Blocks a +// page the window was somehow navigated to (see the will-navigate guard in +// createWindow — this is the second, independent layer) from reaching the +// updater bridge. serverPort is 0 until the server binds; reject until then. +function isTrustedSender(event) { + try { + const url = event && event.senderFrame && event.senderFrame.url; + return !!url && serverPort > 0 && url.indexOf('http://127.0.0.1:' + serverPort + '/') === 0; + } catch (_e) { + return false; + } } async function createWindow() { @@ -188,6 +251,18 @@ async function createWindow() { return { action: 'allow' }; }); + // Pin top-level navigation to our own local server. The preload bridge + // (window.codbashDesktop, incl. the powerful updater) is bound to the WINDOW, + // not an origin — so without this, navigating the window elsewhere (a stray + // location.href, a target=_top link, a future CSP regression) would hand that + // page the update-download/install IPC. External http(s) is opened in the real + // browser instead; anything else off-origin is simply blocked. + win.webContents.on('will-navigate', function (event, url) { + if (serverPort > 0 && url.indexOf('http://127.0.0.1:' + serverPort + '/') === 0) return; + event.preventDefault(); + if (/^https?:/i.test(url)) shell.openExternal(url).catch(function () {}); + }); + // Cmd/Ctrl+W would hit the native menu's "Close Window" before the page ever // sees the keystroke. Intercept it here so it can close the ACTIVE TAB instead // (Chrome-like). We forward it to the renderer, which closes a tab or — if @@ -211,65 +286,77 @@ async function createWindow() { } } -// Simple semver-ish "is a newer than b" (major.minor.patch). -function isNewerVersion(a, b) { - const pa = String(a).split('.').map(Number); - const pb = String(b).split('.').map(Number); - for (let i = 0; i < 3; i++) { - if ((pa[i] || 0) > (pb[i] || 0)) return true; - if ((pa[i] || 0) < (pb[i] || 0)) return false; +// ── In-app auto-update (electron-updater) ──────────────────────────────────── +// Real in-place update: the app downloads the new build from GitHub Releases +// (read via latest-mac.yml / latest.yml) and relaunches onto it — no manual DMG +// download. macOS in-place update REQUIRES a signed build (codbash is signed + +// notarized since v7.14.4); Windows uses the NSIS installer. autoDownload is off +// so the renderer's banner controls when the download starts (Download button) +// and when to relaunch (Restart button). If the updater can't apply (unsigned / +// dev / download error) we emit 'error' and the renderer falls back to opening +// the releases page (codbash:open-releases IPC). +let _autoUpdater = null; +let _autoUpdaterDisabled = false; // hard off for this run (dev/smoke) — never retry +// Lifecycle state, the security boundary for the download/install IPC calls (not +// the renderer's button state). Updated from the autoUpdater events below. +let _updateState = 'idle'; // idle | checking | available | downloading | downloaded | error +let _downloadInFlight = false; // guards against a double downloadUpdate() (fast double-click) +let _updateTimer = null; // 6h re-check interval id (so it can be cleared) + +// Lazy + guarded: electron-updater is a packaged runtime dep. In a from-source +// run (npm start) or an unpacked build it can't apply an update, so we skip it +// and let the renderer degrade to the manual releases page. A dev/smoke run is a +// permanent skip; a transient require() failure is NOT latched, so a later call +// can retry rather than silently disabling updates for the whole session. +// +// Event listeners are attached HERE, at creation, so any checkForUpdates() call +// — whoever triggers it and whenever — always has listeners (EventEmitter drops +// events that fire with none attached, which would silently lose a check result). +function getAutoUpdater() { + if (_autoUpdaterDisabled) return null; + if (_autoUpdater) return _autoUpdater; + if (SMOKE || !app.isPackaged) { _autoUpdaterDisabled = true; return null; } + try { + _autoUpdater = require('electron-updater').autoUpdater; + _autoUpdater.autoDownload = false; // renderer controls when to download + _autoUpdater.autoInstallOnAppQuit = true; + _autoUpdater.allowDowngrade = false; // never move users backwards + _autoUpdater.allowPrerelease = false; // stable channel only, explicit + _autoUpdater.on('checking-for-update', function () { _updateState = 'checking'; sendUpdateState('checking'); }); + _autoUpdater.on('update-available', function (info) { _updateState = 'available'; sendUpdateState('available', { version: info && info.version }); }); + _autoUpdater.on('update-not-available', function () { _updateState = 'idle'; sendUpdateState('none'); }); + _autoUpdater.on('download-progress', function (p) { _updateState = 'downloading'; sendUpdateState('downloading', { percent: p ? Math.round(p.percent) : 0 }); }); + _autoUpdater.on('update-downloaded', function (info) { _downloadInFlight = false; _updateState = 'downloaded'; sendUpdateState('downloaded', { version: info && info.version }); }); + _autoUpdater.on('error', function (err) { _downloadInFlight = false; _updateState = 'error'; sendUpdateState('error', { message: String((err && err.message) || err) }); }); + } catch (e) { + // Not latched: a corrupted node_modules today shouldn't kill updates forever. + process.stderr.write('[desktop] electron-updater load failed: ' + ((e && e.message) || e) + '\n'); + return null; } - return false; + return _autoUpdater; } -// Update check via GitHub Releases. We use a NOTIFY model (fetch the latest -// release, and if it's newer, offer to open the download page) rather than -// silent in-place replacement: silent auto-update on macOS requires a signed -// build, and codbash currently ships unsigned. Once a Developer ID signing -// identity is in place this can be swapped for electron-updater's silent flow. -function checkForUpdates(interactive) { - if (SMOKE) return; - const current = app.getVersion(); - const opts = { - host: 'api.github.com', - path: '/repos/vakovalskii/codbash/releases/latest', - headers: { 'User-Agent': 'codbash-desktop', 'Accept': 'application/vnd.github+json' }, - timeout: 8000, - }; - const req = https.get(opts, function (res) { - let d = ''; - res.on('data', function (c) { d += c; }); - res.on('end', function () { - let rel; - try { rel = JSON.parse(d); } catch (_e) { return; } - const latest = String(rel.tag_name || '').replace(/^v/, ''); - if (latest && isNewerVersion(latest, current)) { - const { dialog } = require('electron'); - dialog.showMessageBox({ - type: 'info', - message: 'codbash ' + latest + ' is available', - detail: 'You have ' + current + '. Open the download page?', - buttons: ['Download', 'Later'], - defaultId: 0, - cancelId: 1, - }).then(function (r) { - if (r.response === 0) shell.openExternal(rel.html_url || 'https://github.com/vakovalskii/codbash/releases/latest'); - }); - } else if (interactive) { - const { dialog } = require('electron'); - dialog.showMessageBox({ type: 'info', message: 'codbash is up to date', detail: 'Version ' + current + '.', buttons: ['OK'] }); - } - }); - }); - req.on('error', function () {}); - req.on('timeout', function () { req.destroy(); }); +function sendUpdateState(state, extra) { + if (!win || win.isDestroyed()) return; + try { win.webContents.send('codbash:update-state', Object.assign({ state: state }, extra || {})); } catch (_e) {} +} + +// A check would call update-not-available → 'none' → hide banner. If an update is +// already downloading or sitting downloaded-and-waiting-to-restart, that would +// wipe the user's "ready to restart" affordance. So skip checks in those states. +function maybeCheckForUpdates() { + const u = getAutoUpdater(); + if (!u) return Promise.resolve(); + if (_updateState === 'downloading' || _updateState === 'downloaded') return Promise.resolve(); + return u.checkForUpdates().catch(function () {}); } function initAutoUpdater() { - if (SMOKE) return; - checkForUpdates(false); - // Re-check every 6 hours while the app stays open. - setInterval(function () { checkForUpdates(false); }, 6 * 60 * 60 * 1000); + const u = getAutoUpdater(); + if (!u) return; // dev / smoke / unpacked — renderer stays on its default UI + // The initial check is triggered by the renderer (wireDesktopUpdater → check), + // which also re-triggers on page reload. Here we only own the periodic re-check. + _updateTimer = setInterval(function () { maybeCheckForUpdates(); }, 6 * 60 * 60 * 1000); } app.whenReady().then(async function () { diff --git a/desktop/package.json b/desktop/package.json index 6c7e74e..c8902b1 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "codbash-desktop", "productName": "codbash", - "version": "7.15.0", + "version": "7.16.0", "private": true, "description": "Desktop shell (Electron) for codbash — wraps the codbash server in a native window.", "main": "main.js", @@ -10,11 +10,16 @@ "scripts": { "start": "electron .", "smoke": "CODBASH_SMOKE=1 electron .", - "dist:mac": "electron-builder --mac dmg", - "release:mac": "electron-builder --mac dmg --publish always", + "dist:mac": "electron-builder --mac --arm64 --x64", + "dist:win": "electron-builder --win nsis", + "release:mac": "electron-builder --mac --arm64 --x64 --publish always", + "refresh-update-feed": "node scripts/regenerate-latest-mac.js", "pack": "electron-builder --dir", "dev": "bash dev.sh" }, + "dependencies": { + "electron-updater": "^6.8.9" + }, "devDependencies": { "@electron/notarize": "^2.5.0", "electron": "^33.2.0", @@ -63,6 +68,13 @@ "arm64", "x64" ] + }, + { + "target": "zip", + "arch": [ + "arm64", + "x64" + ] } ], "icon": "build/icon.png", @@ -74,6 +86,23 @@ }, "dmg": { "title": "codbash ${version}" + }, + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64", + "arm64" + ] + } + ], + "icon": "build/icon.png" + }, + "nsis": { + "oneClick": true, + "perMachine": false, + "allowToChangeInstallationDirectory": false } } } diff --git a/desktop/preload.js b/desktop/preload.js index da58087..f834c78 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -15,4 +15,17 @@ contextBridge.exposeInMainWorld('codbashDesktop', { onShortcut: (cb) => ipcRenderer.on('codbash:shortcut', (_e, name) => cb(name)), // Ask main to close the window (used when a shortcut has no in-page meaning). closeWindow: () => ipcRenderer.send('codbash:close-window'), + // In-app updater (electron-updater). The dashboard's update banner drives this: + // onState(cb) → receives {state, version?, percent?, message?} pushes + // download() → start downloading the available update + // install() → relaunch onto the downloaded update + // check() → force a check now + // openReleases() → fallback: open the GitHub releases page in the browser + updater: { + onState: (cb) => ipcRenderer.on('codbash:update-state', (_e, s) => cb(s)), + check: () => ipcRenderer.invoke('codbash:update-check'), + download: () => ipcRenderer.invoke('codbash:update-download'), + install: () => ipcRenderer.invoke('codbash:update-install'), + openReleases: () => ipcRenderer.send('codbash:open-releases'), + }, }); diff --git a/desktop/scripts/regenerate-latest-mac.js b/desktop/scripts/regenerate-latest-mac.js new file mode 100644 index 0000000..00217b9 --- /dev/null +++ b/desktop/scripts/regenerate-latest-mac.js @@ -0,0 +1,160 @@ +'use strict'; + +// Refresh electron-updater's macOS feed (dist/latest-mac.yml) after the DMG +// containers are notarized + stapled. +// +// Why: stapling mutates the DMG bytes, so any feed entry pointing at a file that +// changed on disk needs a fresh sha512/size/blockMapSize or the updater rejects +// the download. With the zip target #272 ships, the *.zip entries (the actual +// mac update artifact) are NOT touched by stapling, so this is a no-op for them +// — only a changed file (e.g. a stapled DMG that appears in the feed) is +// refreshed. Detection is by sha512 mismatch, so re-running is idempotent. +// +// Unlike a hand-authored feed, this PARSES electron-builder's own latest-mac.yml +// and only rewrites the numbers in place — it never invents the schema, the arch +// mapping, or the `path` choice, so it can't silently produce a broken feed. +// Generalises the idea from PR #271 (thanks @indapublic) to the zip+dmg feed. +// +// ⚠️ Validate on the next real signed build: run `npm run refresh-update-feed` +// after stapling and confirm `dist/latest-mac.yml` still matches the uploaded +// artifacts (electron-updater 404s / checksum-fails loudly if it doesn't). + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const ROOT = path.join(__dirname, '..'); +const DIST = path.join(ROOT, 'dist'); +const FEED = path.join(DIST, 'latest-mac.yml'); + +function sha512Base64(file) { + return crypto.createHash('sha512').update(fs.readFileSync(file)).digest('base64'); +} + +function findAppBuilder() { + const pkgPath = require.resolve('app-builder-bin/package.json', { paths: [ROOT] }); + const dir = path.dirname(pkgPath); + const candidates = [ + path.join(dir, 'mac', process.arch === 'arm64' ? 'app-builder_arm64' : 'app-builder_x64'), + path.join(dir, 'mac', 'app-builder'), + path.join(dir, 'mac', 'app-builder_arm64'), + path.join(dir, 'mac', 'app-builder_x64'), + ]; + const found = candidates.find((p) => fs.existsSync(p)); + if (!found) throw new Error('app-builder binary not found under ' + dir); + return found; +} + +function regenerateBlockmap(appBuilder, file) { + const blockmap = file + '.blockmap'; + execFileSync(appBuilder, ['blockmap', '--input', file, '--output', blockmap], { stdio: 'inherit' }); + return fs.statSync(blockmap).size; +} + +// ── Pure helpers (unit-tested; no disk/electron-builder needed) ─────────────── + +// Parse the files[] entries of a latest-mac.yml into [{url, sha512, size, +// blockMapSize}]. Tolerant of key order; a top-level (column-0) key ends a block. +function parseFeedEntries(text) { + const lines = text.split(/\r?\n/); + const entries = []; + let cur = null; + let itemIndent = -1; + for (const line of lines) { + const item = line.match(/^(\s*)-\s*url:\s*(.+?)\s*$/); + if (item) { + cur = { url: item[2] }; + itemIndent = item[1].length; + entries.push(cur); + continue; + } + if (/^\S/.test(line)) { cur = null; continue; } // column-0 key ends the block + if (!cur) continue; + const kv = line.match(/^(\s*)(sha512|size|blockMapSize):\s*(.+?)\s*$/); + if (kv && kv[1].length > itemIndent) { + if (kv[2] === 'size' || kv[2] === 'blockMapSize') cur[kv[2]] = Number(kv[3]); + else cur[kv[2]] = kv[3]; + } + } + return entries; +} + +// Rewrite sha512/size/blockMapSize for each url present in infoByUrl, and sync +// the top-level `sha512:` to the file named by the top-level `path:`. Any field +// left undefined in infoByUrl[url] is preserved as-is. +function rewriteFeedYaml(text, infoByUrl) { + const eol = text.indexOf('\r\n') !== -1 ? '\r\n' : '\n'; + const lines = text.split(/\r?\n/); + let curUrl = null; + let itemIndent = -1; + let pathUrl = null; + const out = lines.map((line) => { + const item = line.match(/^(\s*)-\s*url:\s*(.+?)\s*$/); + if (item) { curUrl = item[2]; itemIndent = item[1].length; return line; } + const topKey = line.match(/^(\S[^:]*):\s*(.*)$/); + if (topKey) { + curUrl = null; + if (topKey[1] === 'path') { pathUrl = topKey[2].trim(); return line; } + if (topKey[1] === 'sha512' && pathUrl && infoByUrl[pathUrl] && infoByUrl[pathUrl].sha512 != null) { + return 'sha512: ' + infoByUrl[pathUrl].sha512; + } + return line; + } + if (curUrl && infoByUrl[curUrl]) { + const kv = line.match(/^(\s*)(sha512|size|blockMapSize):\s*(.+?)\s*$/); + if (kv && kv[1].length > itemIndent) { + const info = infoByUrl[curUrl]; + if (kv[2] === 'sha512' && info.sha512 != null) return kv[1] + 'sha512: ' + info.sha512; + if (kv[2] === 'size' && info.size != null) return kv[1] + 'size: ' + info.size; + if (kv[2] === 'blockMapSize' && info.blockMapSize != null) return kv[1] + 'blockMapSize: ' + info.blockMapSize; + } + } + return line; + }); + return out.join(eol); +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +function main() { + if (!fs.existsSync(FEED)) { + throw new Error('missing ' + FEED + ' — build the mac targets first (dmg + zip)'); + } + const text = fs.readFileSync(FEED, 'utf8'); + const entries = parseFeedEntries(text); + if (!entries.length) throw new Error('no files[] entries found in ' + FEED); + + let appBuilder = null; + const infoByUrl = {}; + let changed = 0; + for (const e of entries) { + const file = path.join(DIST, e.url); + if (!fs.existsSync(file)) throw new Error('feed references a missing artifact: ' + file); + const sha = sha512Base64(file); + const size = fs.statSync(file).size; + const info = { sha512: sha, size }; + if (sha !== e.sha512) { + // Bytes changed since build (e.g. a stapled DMG) — its blockmap is stale too. + const bm = file + '.blockmap'; + if (fs.existsSync(bm)) { + appBuilder = appBuilder || findAppBuilder(); + info.blockMapSize = regenerateBlockmap(appBuilder, file); + } + changed++; + console.log('[feed] refreshed ' + e.url + ' (bytes changed)'); + } else { + console.log('[feed] unchanged ' + e.url); + } + infoByUrl[e.url] = info; + } + + fs.writeFileSync(FEED, rewriteFeedYaml(text, infoByUrl)); + console.log('[feed] wrote ' + FEED + ' (' + changed + '/' + entries.length + ' entries refreshed)'); +} + +module.exports = { parseFeedEntries, rewriteFeedYaml }; + +if (require.main === module) { + try { main(); } catch (e) { console.error('[feed] ' + ((e && e.message) || e)); process.exit(1); } +} diff --git a/docs/design/missing-project-detection.md b/docs/design/missing-project-detection.md new file mode 100644 index 0000000..a248d91 --- /dev/null +++ b/docs/design/missing-project-detection.md @@ -0,0 +1,69 @@ +# Missing-project detection + re-clone offer + +## Цель +Когда зарегистрированный проект удалён/перемещён на диске, Projects-лаунчер должен +это заметить: показать дисклеймер на плитке и, при попытке запуска, вернуть понятную +ошибку «папка отсутствует» с предложением заново скачать актуальную версию с GitHub. + +## Проблема +`projects.json` хранит путь проекта. Если пользователь удалил репо (`rm -rf`), +плитка остаётся, а запуск (`POST /api/launch`) падает с общей ошибкой +`invalid or unsafe project path` — пользователь не понимает причину. + +## Данные +- Реестр: `~/.codedash/projects.json` — `{ id, name, path, source, remoteUrl, defaultBranch }`. +- Признак существования на диске выводится динамически (`fs.statSync().isDirectory()`), + в файл не пишется — состояние диска может меняться между запросами. + +## Изменения API +- `GET /api/projects/manual` — к каждому проекту добавляется `exists: boolean`. + `git` вычисляется только когда `exists === true`. +- `POST /api/launch` — если `project` передан и папки нет на диске, вернуть + `400 { ok:false, error:'project folder is missing on disk', missing:true, + remoteUrl, projectId }` вместо общей ошибки. Проверка идёт до `isSafeLaunchPath`, + чтобы отличить «удалено» от «небезопасный путь». +- `POST /api/projects/reclone` — новый маршрут. Тело `{ id }`. Находит проект в + реестре, требует непустой `remoteUrl`, клонирует `remoteUrl` в **исходный** `path` + (не в свежий `~/code/`), чтобы восстановить папку ровно там, где она была. + Переиспользует `cloneRepo` (та же защита: только GitHub-remote, назначение под `$HOME`, + существующий-тот-же-repo → успех). + +## Стыки +- `src/projects.js` — новый экспорт `pathExists(p)`; `cloneRepo` переиспользуется. +- `src/server.js` — GET manual enrich, launch guard, новый reclone-маршрут. +- `src/frontend/app.js` — `mergeRegistryWithSessions` пробрасывает `_exists`/`_remoteUrl`; + `renderLauncherCard` рисует missing-состояние; `recloneProject()`; launch-функции + обрабатывают `data.missing`. +- `src/frontend/styles.css` — `.launcher-card-missing`, `.launcher-card-warning`. + +## UX & Accessibility +**Required UI states:** +- [x] Normal — папка на месте: обычные кнопки запуска. +- [x] Missing — папка удалена: дисклеймер `role="status"`, кнопки «Re-clone» (если есть + GitHub-remote) и «Remove»; кнопки запуска скрыты. +- [x] Loading — кнопка Re-clone: `disabled` + текст «Cloning…». +- [x] Error — reclone/launch fail: toast с текстом ошибки, кнопка возвращается в исходное. +- [x] Success — toast «Re-cloned … from GitHub», перезагрузка реестра → плитка снова обычная. + +**Keyboard/SR:** дисклеймер — `role="status"` (объявляется screen reader'ом); кнопки +имеют `aria-label`; фокус-ринг наследуется от `.git-project-launch-btn:focus-visible`. + +## Риски +- TOCTOU (папку удалили между рендером и кликом) — `addressed_in`: launch-guard в + `/api/launch` возвращает `missing:true`; `handleMissingProjectLaunch` предлагает reclone. +- `missing:true` глобален для `/api/launch`, поэтому обрабатывается на ВСЕХ трёх точках + запуска — `addressed_in`: `app.js launchNewProjectSession`/`resumeLastProjectSession` и + `detail.js launchSession` (session-detail «Resume»). +- Symlink-ancestor обход home-containment в `cloneRepo` (окно «папка отсутствует») — + `addressed_in`: `projects.js realpathOfNearestAncestor` + `isUnderHome` перед `git clone`. +- reclone для manual-проекта вне `$HOME` или с non-GitHub remote — `cloneRepo` вернёт + понятную ошибку, показываем toast (`addressed`: сообщение об ошибке). +- Проект без `remoteUrl` (локальная папка) — кнопка Re-clone не рисуется, только Remove. +- Параллельные reclone одного id — `addressed_in`: `_inFlightReclone` Set в `server.js` (409). +- HTTP-уровневые тесты новых маршрутов (`/api/launch missing`, `/api/projects/reclone`) — + `deferred_to`: follow-up. `startServer` не имеет teardown-seam (интервалы autoSync/heartbeat + держат event loop), поэтому route-тест требует рефактора извлечения маршрутов. Interim + evidence: scratchpad smoke (register → delete → `exists:false` → `missing:true` → reclone + guardrails) — GREEN. Unit-тесты `pathExists`/`cloneRepo` покрыты. +- Systemic (pre-existing, не в этом PR): mutating POST-маршруты не проверяют `Origin` — + `deferred_to`: отдельный follow-up (repo-wide CSRF hardening). diff --git a/docs/design/running-agents-external.md b/docs/design/running-agents-external.md new file mode 100644 index 0000000..abf63b5 --- /dev/null +++ b/docs/design/running-agents-external.md @@ -0,0 +1,129 @@ +# Running Agents = agents in external terminals (focus, don't spawn) + +## Goal + +The Workspace "Running agents" sidebar should list agents actually running in +**external** native terminals (iTerm/Terminal.app/Warp/cmux…), and a click on a +row should **raise that real window** — not open a blank terminal in the folder. +codbash's own browser-pty panes are removed from this list — they are already +visible as Workspace tabs and in Overview → Terminals. + +## Motivation + +Release 7.15.0 introduced the rule "Running Agents lists only agents launched +from codbash" via `_scopeToCodbashAgents` (`src/data.js`) + `pty-registry`: the +list is scoped to agents whose process tree descends from a codbash pty. Side +effects: + +- An agent that **codbash itself** launched in iTerm via `POST /api/launch` + descends from iTerm, not from a codbash pty → it drops out of the list. + codbash opened the window and then pretends nothing is running. +- An agent launched **by hand** in iTerm in the project folder is invisible too. +- And clicking a running-agent row in the current code (`jumpToRunningAgent`), + when no live codbash pane exists for that cwd, calls `openInWorkspace(...)` — + opening a **blank shell** in the folder. A blank shell is not that agent. + +External agents are exactly the ones with no other representation in the UI — +those are the ones to show. + +## Data inventory + +- `getActiveSessions()` (`src/data.js`) scans `ps`, builds an array of live + agents `{pid, sessionId, cwd, kind, status, cpu, memoryMB, _sessionSource}` + and finally passes it through `_scopeToCodbashAgents(...)`, which **drops** the + external ones. +- `pty-registry.js` — a Set of live pty pids codbash itself spawned (one per + Workspace pane). Populated in `terminal.js`. +- Frontend: `activeSessions` (global, from `GET /api/active`) → used by + `_wsRunningByProject()` / `_wsRenderRunningTree()` (the tree), by Overview + (the "Active agents" count), and by the active-badge on session cards. + +## Component map (consumers of /api/active) + +| Consumer | File | Effect of the change | +|---|---|---| +| Running-agents tree | `workspace.js` | Shows external agents only (`!a.local`) | +| Overview "Active agents" stat | `overview.js` | Counts all live agents (external ones visible again) | +| Session-card active badge | `app.js` | More sessions may light up as active — more correct | + +## Data model (contract) + +`_scopeToCodbashAgents` → renamed to `_tagCodbashAgents`. Instead of a filter, a +**tag**: each entry gets a boolean field. + +``` +local: boolean // true if the process descends from a codbash pty (browser pane) + // false — an agent in an external native terminal +``` + +- **All** detected agents are returned (external ones are in the list again). +- Fail-open: if `ps` for the ppid map is unavailable, tag everyone as + `local:false` (showing "something is running" is more honest than hiding it). +- If the pty-registry is empty (no panes open), every live agent is external → + `local:false` for all. This is the desired behavior (previously the list was + empty). + +## Click behavior (state machine) + +A "Running agents" row is always an external agent (`!a.local`), so: + +``` +click(pid, sessionId, cwd) + → POST /api/focus { pid, sessionId } // raise the real window by PID + ok → window brought to front (reuses focusTerminalByPid) + focus failed → toast "Couldn't focus its terminal window" (NOT a blank shell) +``` + +Forbidden transition: opening a new blank terminal/pane as a "stand-in" for the +agent. + +## Touch points (files to change) + +- `src/data.js` — `_scopeToCodbashAgents` → `_tagCodbashAgents` (tag instead of + filter), call site at line 6062. +- `src/frontend/workspace.js` — `_wsRunningByProject` (filter `!a.local`), + `_wsRenderRunningTree` (forward `pid`, "native terminal" heading/tooltip), + `jumpToRunningAgent` (focus by PID instead of openInWorkspace). +- `src/frontend/overview.js` — no changes (the count simply sees external agents + again). + +## Risks + +| Risk | Handling | +|---|---| +| Noise: "every claude on the machine" back in the list | Grouped by project + tagged; the user explicitly wants this. This reverses 7.15.0 (owner-approved PR) | +| `focusTerminalByPid` doesn't know the agent's app (not iTerm/Terminal/Warp/cmux) | Returns an error → toast, not a blank shell. `addressed_in: jumpToRunningAgent focus-then-toast` | +| Immutability: don't mutate input objects | `map` → new objects `{...a, local}` | +| Prototype pollution via pid/ppid | pids coerced to Number; ppid map built from `ps`; keys are not user input | +| Duplicate external+codbash entry for one agent | Dedup already happens in `getActiveSessions` before tagging; the tag creates no new entries | + +## Review findings — triage (code + security review) + +| Finding | Severity | Resolution | +|---|---|---| +| `jumpToRunningAgent`: `cwd`/`kind` now unused | LOW | Fixed — comment noting the vestigial params | +| `_wsPidArg` would round a float via `parseInt` | LOW | Fixed — `Number.isInteger` (parity with the server) | +| Windows: no ppid scan → everyone `local:false`, so codbash panes also land in the external tree (redundant with their tab) | LOW | `accepted_because:` pre-existing win32 limitation (the old code also didn't filter on win32); macOS/Linux is codbash's primary platform; cosmetic redundancy, not a bug | +| Dropping the codbash-only scope → `/api/active` again returns cwd/pid/sessionId for **all** agents of all users on the host; wider over LAN with `--host=0.0.0.0` | LOW | `addressed_in:` warning in the LAN banner (`server.js` listen). `accepted_because:` default is loopback (same-machine); LAN-bind is a deliberate opt-in advanced flag; `ps` was always system-wide. Narrowing the scope by bind address is a follow-up, not bundled into this PR | +| `/api/focus` (and other POSTs) have no Origin/CSRF check; `JSON.parse` regardless of Content-Type | INFO | `deferred_to:` a separate PR — pre-existing (route unchanged), cross-cutting (all state-changing POSTs). Mirror the WS-upgrade same-origin pattern | +| INFO log of pid/sessionId/cwd in `/api/focus`/`ACTIVE` | INFO | No action — no secrets | + +## UX & Accessibility + +The list is not a form; it is a micro-interaction (clicks on list rows). + +**Required UI states:** +- [x] Empty — no external agents → the "Running agents" block is hidden (as today). +- [x] Success — click raised the window; the OS app switch is the feedback (the + window coming forward). No extra toast needed on success. +- [x] Error — focus failed → `showToast(...)` with a clear message. +- [ ] Loading — N/A: focus is instant (osascript), a spinner would be noise. + +**Keyboard:** rows are clickable `div`s. The existing markup is not +keyboard-focusable; this change does not make it worse (scope: introduce no +regression; a full keyboard-navigable list is a follow-up, `deferred_to: issue`). + +**Screen reader:** keep the `title` on rows; add a clear label that a click +raises the native terminal window. + +**Touch targets:** tree rows keep their existing height (unchanged). diff --git a/docs/design/terminal-project-launcher.md b/docs/design/terminal-project-launcher.md new file mode 100644 index 0000000..95cf86a --- /dev/null +++ b/docs/design/terminal-project-launcher.md @@ -0,0 +1,124 @@ +# Terminal Project Launcher + +## Цель + +Дать возможность из вкладки **«Терминал» (Workspace)** одним контролом выбрать +зарегистрированный проект и открыть в его папке in-app терминал — либо чистый +(без агента), либо с автозапуском последнего/выбранного агента — по той же +модели, что и лаунчер на вкладке **Projects**. + +## Проблема (текущее состояние) + +- Новая вкладка терминала открывает pane в **домашней папке** пользователя. +- Per-pane меню `Launch ▾` (`launchAgentInPane`) запускает агента, но **в текущей + папке pane** — то есть для свежей вкладки агент стартует в `~`, а не в проекте + (агенты кёйят историю по `cwd`, поэтому диалог «теряется» — известная ловушка, + см. `msg.cwdFellBack` guard в `workspace.js`). +- Чтобы открыть терминал в папке проекта, надо уйти на вкладку **Projects** и + использовать select «⊞ Terminal ▾» (`spawnProjectTerminals`) — но он открывает + **только чистые** панели, без агента. +- Единого «выбрать проект + (опц.) запустить агента» прямо в Терминале нет. + +## Инвентаризация данных (переиспользуем, ничего нового на бэкенде) + +| Источник | Что даёт | +|----------|----------| +| `window.manualProjects` (`GET /api/projects/manual`) | реестр проектов: `{id,name,path,source,exists,git,remoteUrl}` | +| `window.installedAgents` (`GET /api/agents/installed`) | установленные агенты `{id,label}` | +| `window.codbashSettings` (`GET /api/settings`) | `defaultAgent`, `lastUsedByPath` | +| `pickPreferredTool(path,null)` (app.js) | last-used → default → первый установленный | +| `agentLabel(id)` (app.js) | человекочитаемое имя агента | +| `WORKSPACE_AGENTS` (workspace.js) | команда запуска агента (`claude`, `codex`, …) | +| `openInWorkspace({name,cwd,cmd})` (workspace.js) | открыть вкладку в папке; `cmd` **авто-запускается** только если папка открылась (иначе fallback в `~` без запуска) | + +Все глобальные символы доступны в общем scope (frontend без модулей — app.js и +workspace.js инлайнятся в одну страницу). + +## Карта компонентов + +- **Потребитель**: только фронтенд вкладки Workspace (`src/frontend/workspace.js`). +- **Бэкенд**: изменений нет — запуск идёт через уже существующий in-app pty + (`openInWorkspace` → WS `/ws/terminal`). +- **Покрытие деплоев**: правка чисто во фронте → одинаково работает в npm-CLI + (браузер) и в подписанном desktop-app (Electron оборачивает тот же сервер). + +## Контракт (внутренние функции workspace.js) + +``` +openWorkspaceProjectLauncher(event) // открыть popover, якорь = кнопка +filterWorkspaceProjectLauncher(value) // перерисовать строки по фильтру +_wsProjectLauncherRowsHtml(filter) // HTML строк проектов +wsLaunchProjectTerminal(projPath, projName) // чистый терминал в папке (без агента) +wsLaunchProjectAgent(projPath, projName, tool) // терминал в папке + автозапуск агента +_wsCloseProjectLauncher() // закрыть + вернуть фокус +``` + +`tool` → команда через lookup в `WORKSPACE_AGENTS` (по `cmd`/`id`); если агента +нет в списке — используем сам `id` как команду (best-effort, как per-pane launch). + +## Поведение выбора агента + +- «▶ ‹agent›» использует `pickPreferredTool(projPath, null)` (last-used → default + → первый установленный). +- Select «Agent ▾» — явный выбор из `window.installedAgents`; при выборе обновляем + **in-memory** `window.codbashSettings.lastUsedByPath[projPath] = tool`, чтобы + метка «▶ ‹agent›» в этой сессии отражала выбор. + + > **assumption**: серверную персистентность last-used для Workspace-запусков не + > делаем — `PUT /api/settings` принимает только `defaultAgent`, а `lastUsedByPath` + > пишется сервером лишь через `/api/launch` (нативный терминал). In-app запуски + > эфемерны. Персистентность — follow-up (потребует расширения `PUT /api/settings`). + +## Стыки (какие файлы менять) + +- `src/frontend/workspace.js` — кнопка «+ Project» в тулбаре `.ws-tools`, popover, + хендлеры запуска. Переиспользует `openInWorkspace`. +- `src/frontend/styles.css` — стили popover (переиспользуем токены `.agent-picker` + / launcher-карточек для консистентности). + +## Риски + +| Риск | Митигация | +|------|-----------| +| Агент, запущенный в отсутствующей папке, миспишет историю | `openInWorkspace` уже не авто-запускает `cmd` при `cwdFellBack`; отсутствующие проекты (`exists===false`) показываем disabled | +| Popover перекрывает терминал / не закрывается | Escape + outside-click + close-on-scroll (паттерн `openAgentPicker`) | +| Много проектов — длинный список | Фильтр по имени/пути + скролл-контейнер | +| Нет установленных агентов | Прятать ▶/select, оставлять только ⊞ Terminal | +| XSS через имя/путь проекта | Всё через `escHtml` (как в остальном UI) | + +## UX & Accessibility + +**Целевой WCAG-уровень**: AA. + +**Required UI states**: +- [x] Loading — данные (`manualProjects`/`installedAgents`) уже загружены при init; + если пусто на момент открытия — показываем empty/hint, не спиннер. +- [x] Empty — нет зарегистрированных проектов → «No projects yet — add them on the + Projects tab» + ссылка-переход на Projects. +- [x] Error — папка проекта отсутствует (`exists===false`) → строка disabled с + пометкой «missing»; запуск в fallback-папку не происходит (guard в pty ready). +- [x] Success — новая вкладка терминала открывается и активируется; агент виден в + статус-баре pane. +- [x] Disabled — нет агентов → только ⊞ Terminal; отсутствующая папка → без действий. +- [ ] Partial/Stale — N/A (список читается синхронно из уже загруженного стейта). +- [ ] Optimistic — N/A. + +**Клавиатура**: +- Кнопка «+ Project» — обычный таб-стоп; открытие по Enter/Space. +- При открытии фокус уходит в поле фильтра. +- Tab/Shift+Tab циклит по строкам/кнопкам; Escape закрывает и возвращает фокус на + кнопку-якорь. +- Native `