diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index feb975a..a1bd18a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,7 +54,9 @@ jobs: registry-url: 'https://registry.npmjs.org' - run: pnpm install --frozen-lockfile - - run: pnpm install -wD @vscode/vsce ovsx + # Exact pins — these tools run with VSCE_PAT/OVSX_PAT/NODE_AUTH_TOKEN in + # scope, so a compromised latest release must not be auto-pulled here. + - run: pnpm install -wD @vscode/vsce@3.9.2 ovsx@1.0.1 # ── Compute versions ─────────────────────────────────────── # For each target, we check both package.json AND the registry. @@ -69,12 +71,19 @@ jobs: LOCAL=$(node -p "require('./$PKG').version") # Query VS Code Marketplace for the latest published version - PUBLISHED=$(npx vsce show Nskha.airtable-formula --json 2>/dev/null \ + PUBLISHED=$(pnpm exec vsce show Nskha.airtable-formula --json 2>/dev/null \ | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{ try{console.log(JSON.parse(d).versions[0].version)} catch{console.log('0.0.0')} })" || echo "0.0.0") + # The Marketplace reply is external input that gets interpolated into + # node -e below — accept strict semver only. + if ! [[ "$PUBLISHED" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::warning::Marketplace returned non-semver version '${PUBLISHED}' — ignoring" + PUBLISHED="0.0.0" + fi + echo "Local: ${LOCAL}, Marketplace: ${PUBLISHED}" # Pick the higher version as the base for bumping @@ -118,6 +127,12 @@ jobs: # Query npm for the latest published version PUBLISHED=$(npm view airtable-user-mcp version 2>/dev/null || echo "0.0.0") + # External input interpolated into node -e below — strict semver only + if ! [[ "$PUBLISHED" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::warning::npm returned non-semver version '${PUBLISHED}' — ignoring" + PUBLISHED="0.0.0" + fi + echo "Local: ${LOCAL}, npm: ${PUBLISHED}" # Pick the higher version as the base for bumping @@ -159,6 +174,12 @@ jobs: # Query npm for the latest published version PUBLISHED=$(npm view airtable-user-lsp version 2>/dev/null || echo "0.0.0") + # External input interpolated into node -e below — strict semver only + if ! [[ "$PUBLISHED" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::warning::npm returned non-semver version '${PUBLISHED}' — ignoring" + PUBLISHED="0.0.0" + fi + echo "Local: ${LOCAL}, npm: ${PUBLISHED}" # Pick the higher version as the base for bumping @@ -226,7 +247,7 @@ jobs: # Package cd packages/extension - npx vsce package --no-dependencies + pnpm exec vsce package --no-dependencies VSIX=$(ls *.vsix | head -1) echo "file=packages/extension/${VSIX}" >> $GITHUB_OUTPUT echo "Packaged: ${VSIX}" @@ -236,7 +257,7 @@ jobs: if: | !inputs.dry_run && (inputs.target == 'extension' || inputs.target == 'both') - run: npx vsce publish --packagePath "${{ steps.vsix.outputs.file }}" + run: pnpm exec vsce publish --packagePath "${{ steps.vsix.outputs.file }}" env: VSCE_PAT: ${{ secrets.VSCE_PAT }} @@ -245,7 +266,7 @@ jobs: !inputs.dry_run && (inputs.target == 'extension' || inputs.target == 'both') run: | - npx ovsx publish "${{ steps.vsix.outputs.file }}" --pat $OVSX_PAT + pnpm exec ovsx publish "${{ steps.vsix.outputs.file }}" --pat "$OVSX_PAT" # Verify the version actually landed (Open VSX indexes asynchronously) VERSION="${{ steps.ext_version.outputs.next }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index d964186..ff8474a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,190 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how ## [Unreleased] +### LSP server — fix runtime startup + CI bin-link warning (2026-06-12) + +- **`airtable-user-lsp` was runtime-broken when executed with Node directly** + (both `--stdio` and `--tcp` — i.e. every editor config using + `npx -y airtable-user-lsp` and the daemon's TCP spawn): the bundle imported + the extensionless `vscode-languageserver/node` subpath, which Node's ESM + resolver rejects because that package ships no `exports` map (build tools + resolve it bundler-style, which is why tsup/vitest never caught it). Fixed + with explicit `/node.js` deep imports. A second masked failure — + `Dynamic require of "util" is not supported` from CJS code bundled into the + ESM output — is fixed with a `createRequire` banner in + [`tsup.config.ts`](packages/lsp-server/tsup.config.ts) (same pattern + `bundle-mcp.mjs` already uses). Both modes smoke-tested via the real bin. +- **CI install warning eliminated:** the `airtable-user-lsp` bin pointed at + `dist/index.mjs`, which doesn't exist at install time on fresh checkouts, so + every CI run logged `WARN Failed to create bin … ENOENT`. The bin now points + at a committed launcher shim ([`bin/airtable-user-lsp.mjs`](packages/lsp-server/bin/airtable-user-lsp.mjs)) + that defers to the built entry and prints an actionable error when `dist/` + is missing. +- Known follow-up (pre-existing, unchanged): the extension never bundles an + LSP copy at `dist/lsp/index.mjs`, so the daemon's first spawn candidate is + dead in installed extensions; editors use the npm package instead. + +### Dashboard — named-tunnel hostname display + confirmed prompt saves (2026-06-12) + +- **`TunnelState.namedTunnelHostname`** (new optional shared-protocol field): + the extension now reads the hostname from `cloudflared-named.yml` (same + mechanical format the daemon writes) and the Setup tab shows a + "Configured: " row for the named-tunnel provider, relabels the + input "New Hostname (optional)", and explains that leaving it empty reuses + the configured tunnel ([`types.ts`](packages/shared/src/types.ts), + [`DashboardProvider.ts`](packages/extension/src/webview/DashboardProvider.ts), + [`Setup.tsx`](packages/webview/src/tabs/Setup.tsx)). +- **PromptEditor waits for confirmation**: `savePrompt`/`deletePrompt`/ + `resetPrompt` now return their action id, `markActionDone` records a + consumable per-action result, and the editor navigates back only after the + extension confirms success — a failure keeps the editor open with the + user's input intact and shows an inline error + ([`store.ts`](packages/webview/src/store.ts), [`Prompts.tsx`](packages/webview/src/tabs/Prompts.tsx)). + 3 new store tests cover the id/result round-trip. + +### Dashboard — second-pass dead-end & cosmetics sweep (2026-06-12) + +A targeted second audit (flow dead-ends, unreachable conditional UI, stale +affordances, cosmetics) confirmed 12 more issues; all fixed: + +- **ngrok auth token now has an update path** — once a token was stored, the + input vanished forever (`!ngrokAuthtokenSet` gate) and the existing + `setNgrokAuthtoken` store action was never wired to any UI. A "token + stored" chip with a **Replace…** flow now lets users rotate/update the + token without disabling the tunnel ([`Setup.tsx`](packages/webview/src/tabs/Setup.tsx)). +- **PromptEditor feedback** — Save/Reset/Delete now disable (+`aria-busy`) + while the action is in flight ([`Prompts.tsx`](packages/webview/src/tabs/Prompts.tsx)). +- **Token/PAT buttons** — Copy Token tracks pending state and disables; + Rotate is daemon-scoped (`beginDaemonAction`) and disables with the other + daemon controls; Copy PAT disables while busy ([`store.ts`](packages/webview/src/store.ts)). +- **Credentials form state** — email is now cleared along with password/OTP + on save, and Cancel resets all fields (stale pre-filled email no longer + reappears on "Update credentials") ([`Settings.tsx`](packages/webview/src/tabs/Settings.tsx)). +- **Cosmetics:** input padding unified at `6px 10px` across tunnel/PAT/ + credentials fields; helper text consistently `--fg-subtle`; daemon button + row gap aligned to 8px; chip casing normalized (Ready/Missing/Via + extension); repeated uppercase-label inline styles extracted to a shared + `.uppercase-label` class; the ngrok section's state-dependent spacing + asymmetry (introduced by the hostname-field fix) removed. + +### Setup tab — Cloudflare Named Tunnel hostname field (2026-06-12) + +Selecting **Cloudflare Named Tunnel** and pressing Enable always failed with +"Named tunnel requires a hostname (domain)" — the Setup tab never rendered a +field to enter one (only ngrok had inputs). Fixes: + +- New **Hostname** input shown when `cf-named` is selected, with helper text + (Cloudflare-managed domain, one-time browser login, empty = reuse existing + config) ([`Setup.tsx`](packages/webview/src/tabs/Setup.tsx)). +- `handleEnableTunnel` now sends the hostname for `cf-named` (it previously + sent the ngrok field's value for every provider). +- Same bug class for ngrok: the **Reserved Domain** field was hidden once an + authtoken was stored — it now always renders for ngrok. +- Host-side fallback: if the setup flow is reached without a hostname (e.g. + via a command), an input box prompts for it instead of dead-ending; the + post-setup enable retry uses the resolved hostname (previously re-sent the + original empty value) ([`DashboardProvider.ts`](packages/extension/src/webview/DashboardProvider.ts)). + +### Daemon Stop reliability + dashboard UI/UX hardening (2026-06-12) + +**Daemon Stop button** — root-caused "stop sometimes does nothing": + +- [`daemon-manager.ts`](packages/extension/src/mcp/daemon-manager.ts) `stopDaemon()` + now mirrors the CLI launcher semantics: verifies the shutdown HTTP response + (a 401 from a stale lockfile token no longer counts as success), waits for + the daemon to release `daemon.lock` before reporting done, escalates + SIGTERM → SIGKILL when the daemon answers but won't exit, and reclaims + stale lockfiles so a crashed daemon can't leave the dashboard stuck on + "running". When the port is unreachable, the recorded pid is *not* killed + (PID-reuse safety) — the stale lock is just removed. +- **No more auto-resurrect:** with `useDaemon` + `loginMode: auto`, VS Code + re-querying MCP definitions used to respawn the daemon seconds after the + user stopped it (via `getCredentials → ensureDaemon`). A user-stopped latch + now blocks implicit respawns; explicit Start/Restart clears it. +- Stop failures are surfaced in the UI / `stopDaemon` command instead of + silently reporting success. 6 new DaemonManager tests. + +**Dashboard webview** — 26 confirmed findings from a 44-agent UI/UX audit +(action feedback, state sync, theming, accessibility, content): + +- **Action feedback:** every async store action now tracks pending state + (`refresh`, `selectCustomBrowser`, `setBrowserChoice`, `copyAirtablePat`, + `openStoragePath` were missing it), and pending actions **auto-expire** + (60s default; longer for login/downloads) so a lost `action:result` can + no longer leave buttons disabled forever ([`store.ts`](packages/webview/src/store.ts)). +- **State sync:** `pushState()` is serialized + coalesced (concurrent file-watch + bursts could previously post a stale `state:update` last); the dashboard + re-syncs on `onDidChangeVisibility` when the sidebar is re-opened; + `daemon:start` reports failure when the daemon manager is unavailable; the + toolProfile fallback now includes all 13 categories (was missing + `recordRead`/`recordWrite`); dropped webview messages are logged + ([`DashboardProvider.ts`](packages/extension/src/webview/DashboardProvider.ts), [`vscode.ts`](packages/webview/src/lib/vscode.ts)). +- **Theming:** fixed undefined `--accent-green` token (Overview update badge); + LSP badges and error/warning banners now use theme tokens instead of + hard-coded rgba; new `--border-error/--border-warn/--bg-lsp-*` tokens. +- **Accessibility:** global `:focus-visible` outlines for buttons/inputs/selects + (credentials form and icon-only buttons had none); `--fg-muted` brightened + from 2.86:1 to ~4.8:1 contrast (WCAG AA); login-mode toggle gets + `role="switch"` + aria-label; daemon buttons get `aria-busy`; disabled + buttons get `cursor: not-allowed`. +- **Content:** raw exception text (auth errors, browser download failures) is + now mapped to human-readable guidance with the raw detail in a tooltip + ([`friendlyError.ts`](packages/webview/src/lib/friendlyError.ts)); TOTP and + bearer-token jargon explained inline; tunnel URL row in Daemon Status is + responsive with a Copy button (was fixed 60% truncation, no copy); IDE + detection shows an animated skeleton instead of static "Loading...". + +### Security hardening — critical-tier fixes from full-codebase audit (2026-06-12) + +A multi-agent security audit (71 findings raised, 49 confirmed under adversarial +verification — full report in `.planning/audits/2026-06-12-hardening-audit.md`) +produced these fixes for the highest-impact tier: + +- **`daemon.lock` no longer world-readable** ([`lockfile.js`](packages/mcp-server/src/daemon/lockfile.js)) — + the lockfile carries the plaintext daemon bearer token but was created with + default permissions. `acquire()` now opens with mode `0o600`, `replace()` + stages its temp file at `0o600` (via `safeAtomicWriteFileSync`), and both + apply the same Windows ACL restriction `daemon.token` already used. The LSP + server's `port_lsp` writer ([`lockfile-writer.ts`](packages/lsp-server/src/lockfile-writer.ts)) + stages at `0o600` too so its atomic rename doesn't undo the hardening. The + shared `applyPrivatePermissions` helper ([`token.js`](packages/mcp-server/src/daemon/token.js)) + is now exported and sanitizes `USERNAME`/`USERDOMAIN` before building the + icacls principal. +- **Login credentials moved off the child environment** ([`auth-manager.ts`](packages/extension/src/mcp/auth-manager.ts), + [`login-runner.js`](packages/mcp-server/src/login-runner.js)) — auto-login + previously passed `AIRTABLE_EMAIL`/`AIRTABLE_PASSWORD`/`AIRTABLE_OTP_SECRET` + via env, visible in `/proc//environ` and core dumps. The runner now + requests credentials over the `fork()` IPC channel + (`request-credentials` → `credentials` handshake) with env retained only as + a standalone-use fallback. The VS Code MCP stdio definition + (`registration.ts`) still uses env — VS Code owns that spawn and env is the + only channel there. +- **Unknown tool profile now fails closed** ([`tool-config.js`](packages/mcp-server/src/tool-config.js)) — + a hand-edited or corrupted `tools-config.json` with an unrecognized + `activeProfile` used to silently enable **all 66 tools** including + destructive ones; it now falls back to the `read-only` set and logs a + warning. `tools-config.json` is also written `0o600`. +- **Release workflow supply-chain pinning** ([`release.yml`](.github/workflows/release.yml)) — + `@vscode/vsce@3.9.2` / `ovsx@1.0.1` installed with exact pins, all + invocations use `npx --no-install` (publish tokens are in scope of those + steps), and Marketplace/npm version replies are validated as strict semver + before being interpolated into version-bump scripts. +- **VSIX packaging symlink guard** ([`prepare-package-deps.mjs`](scripts/prepare-package-deps.mjs)) — + the `dereference: true` copy follows every symlink in the copied packages; + a trojanized dependency could ship a symlink at `~/.ssh` or CI credentials + and have the target land in the published VSIX. The build now walks each + package tree (cycle-safe, through directory-symlink targets) and fails if + any symlink resolves outside the workspace `node_modules` tree. +- **Daemon token hygiene** ([`server.js`](packages/mcp-server/src/daemon/server.js), + [`cli.js`](packages/mcp-server/src/cli.js)) — bearer comparison is now + constant-time (`crypto.timingSafeEqual`), and `airtable-user-mcp daemon + status` redacts the bearer token instead of printing it into shell + history/scrollback. + +Tests: mcp-server 273 (incl. new fail-closed profile test), extension 65, +lsp-server 21 — all pass; `check:tool-sync` green; packaging script verified +against the real pnpm tree; IPC handshake smoke-tested end-to-end. + ### Extension — Windsurf renamed to Devin Desktop, legacy-compatible (2026-06-05) Cognition rebranded the Windsurf editor to **Devin Desktop** on 2026-06-02 (in-place OTA rename) and moved workspace AI assets from `.windsurf/` to `.devin/`, keeping `.windsurf/` as a read fallback (Windsurf-import is on by default). diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index a469994..ebbab21 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -562,13 +562,21 @@ export async function activate(context: vscode.ExtensionContext): Promise await toolProfileManager.openConfigFile(); }), vscode.commands.registerCommand('airtable-formula.stopDaemon', async () => { - await daemonManager.stopDaemon(); - vscode.window.showInformationMessage('Airtable Formula: Daemon stopped.'); + const result = await daemonManager.stopDaemon(); + if (result.stopped) { + vscode.window.showInformationMessage(`Airtable Formula: Daemon stopped.${result.reason ? ` (${result.reason})` : ''}`); + } else { + vscode.window.showErrorMessage(`Airtable Formula: Daemon stop failed — ${result.reason ?? 'daemon did not exit'}.`); + } dashboardProvider.refresh(); }), vscode.commands.registerCommand('airtable-formula.restartDaemon', async () => { - await daemonManager.restartDaemon(); - vscode.window.showInformationMessage('Airtable Formula: Daemon restarted.'); + try { + await daemonManager.restartDaemon(); + vscode.window.showInformationMessage('Airtable Formula: Daemon restarted.'); + } catch (err) { + vscode.window.showErrorMessage(`Airtable Formula: Daemon restart failed — ${err instanceof Error ? err.message : String(err)}`); + } dashboardProvider.refresh(); }), vscode.commands.registerCommand('airtableFormula.tunnel.disable', async () => { diff --git a/packages/extension/src/mcp/auth-manager.ts b/packages/extension/src/mcp/auth-manager.ts index 101e093..9a4243f 100644 --- a/packages/extension/src/mcp/auth-manager.ts +++ b/packages/extension/src/mcp/auth-manager.ts @@ -238,24 +238,44 @@ export class AuthManager implements vscode.Disposable { } /** - * Get credentials as env vars for passing to child processes. + * Read stored credentials from SecretStorage. * Returns undefined if no credentials are stored. */ - async getCredentialsEnv(): Promise | undefined> { - // D-02: ensure daemon is running before handing off credentials + async getCredentials(): Promise<{ email: string; password: string; otpSecret?: string } | undefined> { + // D-02: ensure daemon is running before handing off credentials. + // Implicit + best-effort: credentials don't require the daemon, and this + // path runs from provideMcpServerDefinitions — it must neither resurrect + // a daemon the user explicitly stopped nor block login when the daemon + // can't start. if (getSettings().mcp.useDaemon && this._daemonManager) { - await this._daemonManager.ensureDaemon(); + try { + await this._daemonManager.ensureDaemon({ implicit: true }); + } catch { /* user-stopped latch or startup failure — proceed without daemon */ } } const email = await this.getEmail(); const password = await this.getPassword(); if (!email || !password) return undefined; + const otp = await this.getOtpSecret(); + return { email, password, ...(otp ? { otpSecret: otp } : {}) }; + } + + /** + * Get credentials as env vars. ONLY for the VS Code MCP stdio definition + * (registration.ts), where VS Code owns the spawn and env is the only + * channel. Helper scripts we fork ourselves receive credentials over the + * IPC channel instead (_spawnScript) so they never appear in the child's + * environment (/proc//environ, process listings, core dumps). + */ + async getCredentialsEnv(): Promise | undefined> { + const creds = await this.getCredentials(); + if (!creds) return undefined; + const env: Record = { - AIRTABLE_EMAIL: email, - AIRTABLE_PASSWORD: password, + AIRTABLE_EMAIL: creds.email, + AIRTABLE_PASSWORD: creds.password, }; - const otp = await this.getOtpSecret(); - if (otp) env.AIRTABLE_OTP_SECRET = otp; + if (creds.otpSecret) env.AIRTABLE_OTP_SECRET = creds.otpSecret; return env; } @@ -328,16 +348,17 @@ export class AuthManager implements vscode.Disposable { const loginMode = this._getLoginMode(); if (loginMode === 'auto') { - const creds = await this.getCredentialsEnv(); + const creds = await this.getCredentials(); if (!creds) { this._updateState({ status: 'error', error: 'No credentials stored. Save credentials first.' }); return this._state; } this._updateState({ status: 'logging-in' }); try { + // Credentials go over the IPC channel, never the child environment. const result = await this._spawnScript('login-runner.mjs', { - ...creds, ...this._browserEnv(), ...this._profileEnv(), - }); + ...this._browserEnv(), ...this._profileEnv(), + }, undefined, creds); const now = new Date().toISOString(); if (result.ok) { this._updateState({ status: 'valid', userId: result.userId || undefined, lastLogin: now, lastChecked: now, error: undefined }); @@ -513,8 +534,17 @@ export class AuthManager implements vscode.Disposable { /** * Spawn a bundled MCP helper script as a child process. * Returns parsed JSON from stdout. + * + * `credentials` are delivered over the fork() IPC channel on the child's + * request — never via env, which is world-visible on Linux through + * /proc//environ and survives in core dumps. */ - private _spawnScript(scriptName: string, extraEnv?: Record, timeoutMs = 120_000): Promise { + private _spawnScript( + scriptName: string, + extraEnv?: Record, + timeoutMs = 120_000, + credentials?: { email: string; password: string; otpSecret?: string }, + ): Promise { return new Promise((resolve, reject) => { const scriptPath = path.join(this.extensionPath, 'dist', 'mcp', scriptName); const nodeModulesPath = path.join(this.extensionPath, 'dist', 'node_modules'); @@ -530,6 +560,23 @@ export class AuthManager implements vscode.Disposable { execArgv: ['--experimental-vm-modules'], }); + if (credentials) { + child.on('message', (msg: unknown) => { + if ((msg as { type?: string } | null)?.type === 'request-credentials') { + try { + child.send({ + type: 'credentials', + email: credentials.email, + password: credentials.password, + otpSecret: credentials.otpSecret ?? null, + }); + } catch { + // Child exited between request and reply — close handler reports it + } + } + }); + } + let stdout = ''; let stderr = ''; diff --git a/packages/extension/src/mcp/daemon-manager.ts b/packages/extension/src/mcp/daemon-manager.ts index 03140d0..a93a9ec 100644 --- a/packages/extension/src/mcp/daemon-manager.ts +++ b/packages/extension/src/mcp/daemon-manager.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs/promises'; +import { existsSync, rmSync } from 'fs'; import { spawn } from 'child_process'; export interface DaemonStatus { @@ -12,6 +13,8 @@ export interface DaemonStatus { bearerToken: string | null; tunnelUrl: string | null; uptime: number | null; + /** Lockfile uuid — used to verify daemon identity before kill escalation. */ + uuid: string | null; } export interface DaemonConnectionInfo { @@ -26,15 +29,30 @@ export interface DaemonConnectionInfo { const EMPTY_STATUS: DaemonStatus = { running: false, healthy: false, pid: null, port: null, - port_lsp: null, bearerToken: null, tunnelUrl: null, uptime: null, + port_lsp: null, bearerToken: null, tunnelUrl: null, uptime: null, uuid: null, }; +export interface StopResult { + stopped: boolean; + forced: boolean; + reason?: string; +} + export class DaemonManager implements vscode.Disposable { private readonly _onDidChange = new vscode.EventEmitter(); public readonly onDidChange = this._onDidChange.event; private _disposed = false; private _status: DaemonStatus = { ...EMPTY_STATUS }; + /** + * Set when the user explicitly stops the daemon. Implicit ensureDaemon() + * callers (MCP definition provider, credential handoff) respect it so the + * daemon doesn't resurrect seconds after the user pressed Stop. Cleared by + * any explicit start/restart. + */ + private _userStopped = false; + /** Graceful-shutdown wait before kill escalation (overridable in tests). */ + private _stopWaitMs = 10_000; constructor( private readonly configDir: string, @@ -62,7 +80,8 @@ export class DaemonManager implements vscode.Disposable { const lockPath = path.join(this.configDir, 'daemon.lock'); const raw = await fs.readFile(lockPath, 'utf8'); const record = JSON.parse(raw) as Record; - const port = typeof record.port === 'number' ? record.port : null; + const port = typeof record.port === 'number' && Number.isInteger(record.port) + && record.port >= 1 && record.port <= 65535 ? record.port : null; const bearerToken = typeof record.bearerToken === 'string' ? record.bearerToken : null; const healthy = port != null && bearerToken != null ? await this._httpHealthCheck(port, bearerToken) @@ -76,6 +95,7 @@ export class DaemonManager implements vscode.Disposable { bearerToken, tunnelUrl: typeof record.tunnelUrl === 'string' ? record.tunnelUrl : null, uptime: typeof record.startedAt === 'string' ? Date.now() - Date.parse(record.startedAt) : null, + uuid: typeof record.uuid === 'string' && record.uuid.length > 0 ? record.uuid : null, }; this._status = status; return status; @@ -100,8 +120,9 @@ export class DaemonManager implements vscode.Disposable { child.unref(); } - async ensureDaemon(options?: { timeoutMs?: number }): Promise { + async ensureDaemon(options?: { timeoutMs?: number; implicit?: boolean }): Promise { if (this._disposed) throw new Error('DaemonManager disposed'); + if (!options?.implicit) this._userStopped = false; const deadline = Date.now() + (options?.timeoutMs ?? 15_000); let spawned = false; while (Date.now() < deadline) { @@ -118,38 +139,173 @@ export class DaemonManager implements vscode.Disposable { }; } if (!status.running && !spawned) { + if (options?.implicit && this._userStopped) { + throw new Error('Daemon was explicitly stopped by the user; not respawning implicitly.'); + } await this._spawnDetached(); spawned = true; } - await new Promise(resolve => setTimeout(resolve, 200)); + await this._delay(200); } throw new Error('Timed out waiting for daemon startup.'); } - async stopDaemon(): Promise { + /** + * Stop the daemon and only report success once it is actually gone. + * + * Mirrors the CLI launcher's stopDaemon semantics: graceful HTTP shutdown + * (verifying the response — a 401 from a stale lockfile token is a + * failure, not a success), wait for the daemon to release its lockfile, + * escalate to killing the recorded pid when the daemon answers but won't + * exit, and reclaim stale lockfiles so the UI can't get stuck showing a + * dead daemon as "running". + */ + async stopDaemon(): Promise { + this._userStopped = true; const status = await this.getDaemonStatus(); - if (!status.running || status.port == null || status.bearerToken == null) return; + if (!status.running) return { stopped: true, forced: false }; + + // 1) Graceful shutdown request. Distinguish: accepted / rejected (the + // port answered with an error status) / unreachable (no daemon there). + let outcome: 'accepted' | 'rejected' | 'unreachable' = 'unreachable'; + if (status.port != null && status.bearerToken != null) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3_000); + try { + const res = await fetch(`http://127.0.0.1:${status.port}/daemon/shutdown`, { + method: 'POST', + headers: { Authorization: `Bearer ${status.bearerToken}` }, + signal: controller.signal, + }); + outcome = res.ok ? 'accepted' : 'rejected'; + } catch { + outcome = 'unreachable'; + } finally { + clearTimeout(timeout); + } + } + + // 2) Accepted — the daemon releases its lockfile as it exits; wait for it. + if (outcome === 'accepted') { + const deadline = Date.now() + this._stopWaitMs; + while (Date.now() < deadline) { + if (!this._lockfileExists()) return { stopped: true, forced: false }; + await this._delay(200); + } + // Acknowledged but the lock never released — fall through to escalation. + } + + const pid = status.pid; + const pidAlive = typeof pid === 'number' && pid > 0 && this._isPidAlive(pid); + + // 3) Escalate to kill ONLY with proven daemon identity: /daemon/health, + // authenticated with the lockfile's bearer, echoing the lockfile's + // uuid. NEITHER an accepted nor a rejected shutdown response proves + // identity by itself — a stale lock whose port was reused by an + // unrelated local service can produce either (catch-all routes 200 + // anything and ignore the bearer header), while the recorded pid may + // belong to an innocent recycled process. + const provenOurDaemon = outcome !== 'unreachable' + && status.port != null && status.bearerToken != null + && await this._verifyDaemonIdentity(status.port, status.bearerToken, status.uuid); + + if (provenOurDaemon && pidAlive && typeof pid === 'number') { + this._killPid(pid); + const deadline = Date.now() + 3_000; + let escalated = false; + while (Date.now() < deadline && this._isPidAlive(pid)) { + if (!escalated && Date.now() > deadline - 1_500) { + this._killPid(pid, 'SIGKILL'); + escalated = true; + } + await this._delay(100); + } + if (this._isPidAlive(pid)) { + return { stopped: false, forced: true, reason: `Daemon process ${pid} did not exit after kill.` }; + } + this._reclaimLockfile(); + return { stopped: true, forced: true }; + } + + // 4) Unreachable or unproven identity: the lockfile is stale (dead pid), + // or whatever answers on the port could not be verified as our daemon. + // Reclaim the lock so the dashboard stops showing a phantom daemon, + // but leave the recorded pid untouched (PID-reuse safety). + this._reclaimLockfile(); + return { + stopped: true, + forced: false, + reason: pidAlive + ? `Removed stale daemon.lock; process ${pid} could not be verified as the daemon and was left untouched.` + : undefined, + }; + } + + /** + * Proof of identity for kill escalation: /daemon/health, authenticated with + * the lockfile's bearer token, must echo the lockfile's uuid. + */ + private async _verifyDaemonIdentity(port: number, bearerToken: string, uuid: string | null): Promise { + if (!uuid) return false; const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3_000); + const timeout = setTimeout(() => controller.abort(), 2_000); try { - await fetch(`http://127.0.0.1:${status.port}/daemon/shutdown`, { - method: 'POST', - headers: { Authorization: `Bearer ${status.bearerToken}` }, + const res = await fetch(`http://127.0.0.1:${port}/daemon/health`, { + headers: { Authorization: `Bearer ${bearerToken}` }, signal: controller.signal, }); + if (!res.ok) return false; + const body = await res.json().catch(() => null) as { uuid?: unknown } | null; + return body?.uuid === uuid; } catch { - // daemon terminates itself; ignore network errors + return false; } finally { clearTimeout(timeout); } } async restartDaemon(): Promise { - await this.stopDaemon(); - await new Promise(resolve => setTimeout(resolve, 500)); + const stop = await this.stopDaemon(); + if (!stop.stopped) { + // Proceeding would let ensureDaemon() find the old daemon's lockfile + // and "restart" by reconnecting to the very process that refused to die. + throw new Error(`Restart aborted — the running daemon could not be stopped: ${stop.reason ?? 'unknown reason'}`); + } + await this._delay(500); return this.ensureDaemon(); } + // ─── Stop helpers (instance methods so tests can stub process control) ── + + private _lockfileExists(): boolean { + return existsSync(path.join(this.configDir, 'daemon.lock')); + } + + private _reclaimLockfile(): void { + try { + rmSync(path.join(this.configDir, 'daemon.lock'), { force: true }); + } catch { /* best-effort */ } + } + + private _isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException)?.code === 'EPERM'; + } + } + + private _killPid(pid: number, signal: NodeJS.Signals = 'SIGTERM'): void { + try { + process.kill(pid, signal); + } catch { /* already gone */ } + } + + private _delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + buildDaemonEnv(credEnv?: Record): Record { const env: Record = { AIRTABLE_USER_MCP_HOME: this.configDir, diff --git a/packages/extension/src/test/daemon-manager.test.ts b/packages/extension/src/test/daemon-manager.test.ts index cd99ecc..ff3d000 100644 --- a/packages/extension/src/test/daemon-manager.test.ts +++ b/packages/extension/src/test/daemon-manager.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as os from 'os'; import * as path from 'path'; import * as fs from 'fs'; @@ -79,6 +79,222 @@ describe('DaemonManager.probeHealth', () => { }); }); +describe('DaemonManager.stopDaemon', () => { + let tmpDir: string; + let dm: InstanceType; + let server: import('http').Server | undefined; + + const writeLock = (port: number, pid = process.pid, token = 'test-token') => { + fs.writeFileSync(path.join(tmpDir, 'daemon.lock'), JSON.stringify({ + pid, uuid: 'uuid-1', port, port_lsp: null, bearerToken: token, + version: '0.0.0', startedAt: new Date().toISOString(), tunnelUrl: null, + })); + }; + const lockExists = () => fs.existsSync(path.join(tmpDir, 'daemon.lock')); + + const listen = async (handler: import('http').RequestListener): Promise => { + const http = await import('http'); + server = http.createServer(handler); + await new Promise(r => server!.listen(0, '127.0.0.1', r)); + return (server!.address() as import('net').AddressInfo).port; + }; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-daemon-stop-')); + dm = new DaemonManager(tmpDir, '/tmp/test-ext-path'); + }); + + afterEach(async () => { + if (server) { await new Promise(r => server!.close(() => r())); server = undefined; } + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns stopped:true immediately when no lockfile exists', async () => { + const result = await dm.stopDaemon(); + expect(result.stopped).toBe(true); + expect(result.forced).toBe(false); + }); + + it('graceful: waits for the daemon to release its lockfile before resolving', async () => { + const port = await listen((req, res) => { + if (req.method === 'POST' && req.url === '/daemon/shutdown') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"ok":true}'); + // Simulate the daemon releasing the lock shortly after replying + setTimeout(() => fs.rmSync(path.join(tmpDir, 'daemon.lock'), { force: true }), 150); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"ok":true}'); + }); + writeLock(port); + + const result = await dm.stopDaemon(); + expect(result.stopped).toBe(true); + expect(result.forced).toBe(false); + expect(lockExists()).toBe(false); + }); + + it('rejected shutdown with PROVEN identity (health echoes lock uuid): escalates to kill', async () => { + const port = await listen((req, res) => { + if (req.method === 'GET' && req.url === '/daemon/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, uuid: 'uuid-1' })); + return; + } + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end('{"error":"Unauthorized"}'); + }); + writeLock(port, 12345, 'stale-token'); + + let killed = false; + (dm as any)._killPid = vi.fn(() => { killed = true; }); + (dm as any)._isPidAlive = vi.fn(() => !killed); + + const result = await dm.stopDaemon(); + expect((dm as any)._killPid).toHaveBeenCalledWith(12345); + expect(result.stopped).toBe(true); + expect(result.forced).toBe(true); + expect(lockExists()).toBe(false); + }); + + it('rejected shutdown WITHOUT proven identity: does NOT kill (PID reuse), reclaims the lock', async () => { + // Simulates a stale lock whose port was reused by an unrelated HTTP + // service: it answers (non-2xx) but cannot echo the lockfile uuid. + const port = await listen((_req, res) => { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end('{"error":"Not found"}'); + }); + writeLock(port, process.pid); + + (dm as any)._killPid = vi.fn(); + (dm as any)._isPidAlive = vi.fn(() => true); + + const result = await dm.stopDaemon(); + expect((dm as any)._killPid).not.toHaveBeenCalled(); + expect(result.stopped).toBe(true); + expect(result.reason).toBeTruthy(); + expect(lockExists()).toBe(false); + }); + + it('accepted shutdown from an impostor (2xx, no uuid proof): does NOT kill, reclaims the lock', async () => { + // A catch-all local service can 200 a POST /daemon/shutdown while + // ignoring the bearer header — and it will never release our lockfile. + const port = await listen((req, res) => { + res.writeHead(200, { 'Content-Type': req.url === '/daemon/health' ? 'text/html' : 'application/json' }); + res.end(req.url === '/daemon/health' ? 'not the daemon' : '{"ok":true}'); + }); + writeLock(port, process.pid); + (dm as any)._stopWaitMs = 300; + (dm as any)._killPid = vi.fn(); + (dm as any)._isPidAlive = vi.fn(() => true); + + const result = await dm.stopDaemon(); + expect((dm as any)._killPid).not.toHaveBeenCalled(); + expect(result.stopped).toBe(true); + expect(result.reason).toBeTruthy(); + expect(lockExists()).toBe(false); + }); + + it('accepted shutdown but wedged daemon (uuid proven): escalates to kill', async () => { + const port = await listen((req, res) => { + if (req.method === 'GET' && req.url === '/daemon/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, uuid: 'uuid-1' })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"ok":true}'); + // Wedged: never releases the lockfile + }); + writeLock(port, 23456); + (dm as any)._stopWaitMs = 300; + let killed = false; + (dm as any)._killPid = vi.fn(() => { killed = true; }); + (dm as any)._isPidAlive = vi.fn(() => !killed); + + const result = await dm.stopDaemon(); + expect((dm as any)._killPid).toHaveBeenCalledWith(23456); + expect(result.stopped).toBe(true); + expect(result.forced).toBe(true); + expect(lockExists()).toBe(false); + }); + + it('unreachable daemon with dead pid: reclaims the stale lock without killing anything', async () => { + writeLock(1, 999_999); // port 1 — nothing listening + (dm as any)._killPid = vi.fn(); + (dm as any)._isPidAlive = vi.fn(() => false); + + const result = await dm.stopDaemon(); + expect((dm as any)._killPid).not.toHaveBeenCalled(); + expect(result.stopped).toBe(true); + expect(lockExists()).toBe(false); + }); + + it('unreachable daemon with live pid: does NOT kill (PID-reuse safety) but reclaims the lock', async () => { + writeLock(1, process.pid); + (dm as any)._killPid = vi.fn(); + (dm as any)._isPidAlive = vi.fn(() => true); + + const result = await dm.stopDaemon(); + expect((dm as any)._killPid).not.toHaveBeenCalled(); + expect(result.stopped).toBe(true); + expect(result.reason).toBeTruthy(); + expect(lockExists()).toBe(false); + }); +}); + +describe('DaemonManager.restartDaemon', () => { + let tmpDir: string; + let dm: InstanceType; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-daemon-restart-')); + dm = new DaemonManager(tmpDir, '/tmp/test-ext-path'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('aborts (throws) when the running daemon could not be stopped', async () => { + (dm as any).stopDaemon = vi.fn(async () => ({ stopped: false, forced: true, reason: 'process 1 did not exit' })); + (dm as any)._spawnDetached = vi.fn(); + await expect(dm.restartDaemon()).rejects.toThrow(/could not be stopped/); + expect((dm as any)._spawnDetached).not.toHaveBeenCalled(); + }); +}); + +describe('DaemonManager user-stopped latch', () => { + let tmpDir: string; + let dm: InstanceType; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-daemon-latch-')); + dm = new DaemonManager(tmpDir, '/tmp/test-ext-path'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('implicit ensureDaemon refuses to respawn after an explicit stop', async () => { + (dm as any)._spawnDetached = vi.fn(); + await dm.stopDaemon(); // no lock — still sets the latch + await expect(dm.ensureDaemon({ implicit: true, timeoutMs: 500 })).rejects.toThrow(/stopped/i); + expect((dm as any)._spawnDetached).not.toHaveBeenCalled(); + }); + + it('explicit ensureDaemon clears the latch and attempts a spawn', async () => { + (dm as any)._spawnDetached = vi.fn(); + await dm.stopDaemon(); + await expect(dm.ensureDaemon({ timeoutMs: 400 })).rejects.toThrow(/Timed out/); + expect((dm as any)._spawnDetached).toHaveBeenCalled(); + // Latch cleared — implicit calls may spawn again now + await expect(dm.ensureDaemon({ implicit: true, timeoutMs: 400 })).rejects.toThrow(/Timed out/); + }); +}); + describe('createHttpDefinition', () => { it('returns null when McpHttpServerDefinition is not on vscode namespace', () => { // vscode mock does not include McpHttpServerDefinition — expect null diff --git a/packages/extension/src/webview/DashboardProvider.ts b/packages/extension/src/webview/DashboardProvider.ts index 815928d..b2fa543 100644 --- a/packages/extension/src/webview/DashboardProvider.ts +++ b/packages/extension/src/webview/DashboardProvider.ts @@ -71,6 +71,11 @@ export class DashboardProvider implements vscode.WebviewViewProvider { }; webviewView.webview.html = getWebviewHtml(webviewView.webview, this.context); webviewView.webview.onDidReceiveMessage(msg => this.handleMessage(msg as WebviewMessage)); + // Re-sync when the sidebar is re-opened — daemon/tunnel/auth state may + // have changed while the view was hidden and no watcher fired since. + webviewView.onDidChangeVisibility(() => { + if (webviewView.visible) void this.pushState(); + }); } private async handleMessage(msg: WebviewMessage): Promise { @@ -450,6 +455,38 @@ export class DashboardProvider implements vscode.WebviewViewProvider { authtoken = await this.context.secrets.get('airtable-formula.ngrok.authtoken') ?? undefined; } } + + // cf-named with an explicit hostname: run named-create FIRST. The + // daemon's enable-tunnel starts from the on-disk YAML, so without + // this pre-step a changed hostname would be silently ignored and the + // old one kept serving. named-create is idempotent for the same + // hostname and reconfigures (route dns + YAML rewrite) for a new one. + if (msg.provider === 'cf-named' && msg.domain) { + try { + const createResp = await fetch(`http://127.0.0.1:${status.port}/daemon/tunnel/named-create`, { + method: 'POST', + headers: { Authorization: `Bearer ${status.bearerToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ hostname: msg.domain }), + signal: AbortSignal.timeout(60_000), + }); + if (!createResp.ok) { + const b = await createResp.json().catch(() => ({})) as Record; + const createErr = typeof b.error === 'string' ? b.error : `HTTP ${createResp.status}`; + if (/not installed|login required|cert/i.test(createErr)) { + // First-time setup missing — fall through; enable-tunnel's + // error path routes into the full setup wizard below. + } else { + // Real failure (e.g. DNS route rejected). Abort rather than + // silently starting the tunnel on the previous hostname. + void vscode.window.showErrorMessage(`Tunnel hostname configuration failed: ${createErr}`); + this.postResult(msg.id, false, createErr); + await this.pushState(); + return; + } + } + } catch { /* daemon unreachable — the enable call below surfaces it */ } + } + const enableResp = await fetch(`http://127.0.0.1:${status.port}/daemon/enable-tunnel`, { method: 'POST', headers: { @@ -516,9 +553,9 @@ export class DashboardProvider implements vscode.WebviewViewProvider { // feedback, then run ensureDaemon in the background (can take ~15s). this._daemonStarting = true; void this.pushState(); - this.postResult(msg.id, true); const dm = this._daemonManager; if (dm) { + this.postResult(msg.id, true); dm.restartDaemon() .then(() => { this._daemonStarting = false; void this._initLockfileWatch(); return this.pushState(); }) .catch(err => { @@ -529,15 +566,26 @@ export class DashboardProvider implements vscode.WebviewViewProvider { } else { this._daemonStarting = false; void this.pushState(); + this.postResult(msg.id, false, 'Daemon manager unavailable'); } return; } if (msg.type === 'daemon:stop') { try { - await this._daemonManager?.stopDaemon(); + const result = await this._daemonManager?.stopDaemon(); await this.pushState(); - this.postResult(msg.id, true); + if (result && !result.stopped) { + const reason = result.reason ?? 'Daemon did not exit.'; + vscode.window.showErrorMessage(`Daemon stop failed: ${reason}`); + this.postResult(msg.id, false, reason); + } else { + if (result?.reason) { + // Stopped, but with a caveat (e.g. stale lock cleaned up) — inform, don't alarm. + vscode.window.showInformationMessage(`Daemon stopped: ${result.reason}`); + } + this.postResult(msg.id, true); + } } catch (err) { vscode.window.showErrorMessage(`Daemon stop failed: ${err instanceof Error ? err.message : String(err)}`); this.postResult(msg.id, false, String(err)); @@ -701,7 +749,36 @@ export class DashboardProvider implements vscode.WebviewViewProvider { } } - async pushState(): Promise { + // pushState is async and reads daemon.lock / settings from disk; concurrent + // runs (e.g. several fs.watch events in a burst) can finish out of order and + // post a STALE state:update last. Serialize: one run at a time, and coalesce + // requests that arrive mid-run into a single trailing re-run. + private _pushInFlight: Promise | null = null; + private _pushQueued = false; + + pushState(): Promise { + // The lockfile watcher fails silently when ~/.airtable-user-mcp doesn't + // exist yet (cold start before any daemon spawn). Retry here — by the + // time state changes are worth pushing, the daemon has created the dir. + if (!this._lockfileWatcher && this._daemonManager) void this._initLockfileWatch(); + if (this._pushInFlight) { + this._pushQueued = true; + return this._pushInFlight; + } + this._pushInFlight = (async () => { + try { + do { + this._pushQueued = false; + await this._computeAndPostState(); + } while (this._pushQueued); + } finally { + this._pushInFlight = null; + } + })(); + return this._pushInFlight; + } + + private async _computeAndPostState(): Promise { if (!this.view) return; this._debugCollector?.trace('ext', 'webview', 'webview:message_out', { type: 'state:update', @@ -731,15 +808,15 @@ export class DashboardProvider implements vscode.WebviewViewProvider { // during very early activation. const toolProfile: ToolProfileSnapshot = this.toolProfileManager?.getSnapshot() ?? { profile: 'full', - enabledCount: 62, - totalCount: 62, + enabledCount: 66, + totalCount: 66, categories: { - read: true, + read: true, recordRead: true, tableWrite: true, tableDestructive: true, fieldWrite: true, fieldDestructive: true, viewWrite: true, viewDestructive: true, viewSection: true, viewSectionDestructive: true, - formWrite: true, + formWrite: true, recordWrite: true, extension: true, }, }; @@ -909,6 +986,27 @@ export class DashboardProvider implements vscode.WebviewViewProvider { // Check SecretStorage for ngrok authtoken const ngrokAuthtokenSet = !!(await this.context.secrets.get('airtable-formula.ngrok.authtoken')); + // Read the named-tunnel hostname from cloudflared-named.yml (written by + // the daemon's writeTunnelConfig — fixed mechanical format, same + // `- hostname:` extraction as the daemon's parseConfigYaml). + let namedTunnelHostname: string | null = null; + const namedConfigPath = pathMod.join(configDir, 'cloudflared-named.yml'); + if (fsMod.existsSync(namedConfigPath)) { + try { + const rawYaml = fsMod.readFileSync(namedConfigPath, 'utf8'); + for (const line of rawYaml.split(/\r?\n/)) { + const m = line.trim().match(/^- hostname:\s*(.+)$/u); + if (m) { + const value = m[1].trim(); + namedTunnelHostname = value.startsWith('"') && value.endsWith('"') + ? value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\') + : value; + break; + } + } + } catch { /* unreadable — treat as not configured */ } + } + // Determine TunnelStatus let status: import('@airtable-formula/shared').TunnelStatus = 'disabled'; if (tunnelUrl) { @@ -925,6 +1023,7 @@ export class DashboardProvider implements vscode.WebviewViewProvider { provider, ngrokAuthtokenSet, autoDisabledReason, + namedTunnelHostname, }; } catch { return undefined; @@ -1084,8 +1183,20 @@ export class DashboardProvider implements vscode.WebviewViewProvider { } // Step 2: Create tunnel (no-op if already configured) - const hostname = originalMsg.domain; - if (!hostname) throw new Error('Named tunnel requires a hostname (domain). Enter it in the tunnel settings.'); + let hostname = originalMsg.domain; + if (!hostname) { + // Last-resort prompt — the Setup tab has a Hostname field, but the + // flow can also be reached from commands that never showed it. + hostname = await vscode.window.showInputBox({ + title: 'Cloudflare Named Tunnel', + prompt: 'Hostname for the tunnel — a domain you manage in Cloudflare', + placeHolder: 'mcp.your-domain.com', + ignoreFocusOut: true, + validateInput: (v) => (v.trim().length === 0 ? 'Hostname is required for first-time setup' : undefined), + }); + hostname = hostname?.trim() || undefined; + } + if (!hostname) throw new Error('Named tunnel requires a hostname (domain). Enter it in the Hostname field under the tunnel provider in the Setup tab.'); progress.report({ message: `Creating tunnel for ${hostname}…` }); const createResp = await fetch(`${base}/daemon/tunnel/named-create`, { method: 'POST', headers, @@ -1097,11 +1208,12 @@ export class DashboardProvider implements vscode.WebviewViewProvider { throw new Error(`Tunnel creation failed: ${b.error ?? createResp.status}`); } - // Step 3: Retry enable-tunnel now that setup is complete + // Step 3: Retry enable-tunnel now that setup is complete — with the + // RESOLVED hostname (originalMsg.domain may have been empty). progress.report({ message: 'Starting tunnel…' }); const enableResp = await fetch(`${base}/daemon/enable-tunnel`, { method: 'POST', headers, - body: JSON.stringify({ provider: originalMsg.provider, domain: originalMsg.domain }), + body: JSON.stringify({ provider: originalMsg.provider, domain: hostname }), signal: AbortSignal.timeout(90_000), }); if (!enableResp.ok) { diff --git a/packages/lsp-server/bin/airtable-user-lsp.mjs b/packages/lsp-server/bin/airtable-user-lsp.mjs new file mode 100644 index 0000000..ac59c68 --- /dev/null +++ b/packages/lsp-server/bin/airtable-user-lsp.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +// Committed launcher for the `airtable-user-lsp` bin. +// +// The real entry lives in dist/ (built by tsup). Pointing the bin map at the +// build artifact breaks `pnpm install` on fresh checkouts — the file doesn't +// exist yet, so pnpm warns and skips creating the bin link (visible as a +// WARN on every CI install). This shim is checked into git, so the link is +// always created; it defers to the built entry at run time. The entry reads +// process.argv itself, so --stdio/--tcp pass through unchanged. +import('../dist/index.mjs').catch((err) => { + const msg = String(err?.message ?? err); + if (err?.code === 'ERR_MODULE_NOT_FOUND' && /dist[\\/]index\.mjs/.test(msg)) { + console.error('[airtable-user-lsp] dist/index.mjs missing — run `pnpm -F airtable-user-lsp build` first.'); + } else { + console.error('[airtable-user-lsp] failed to start:', msg); + } + process.exit(1); +}); diff --git a/packages/lsp-server/package.json b/packages/lsp-server/package.json index 552ac1b..e2f2424 100644 --- a/packages/lsp-server/package.json +++ b/packages/lsp-server/package.json @@ -5,12 +5,13 @@ "type": "module", "main": "dist/index.mjs", "bin": { - "airtable-user-lsp": "dist/index.mjs" + "airtable-user-lsp": "bin/airtable-user-lsp.mjs" }, "engines": { "node": ">=20" }, "files": [ + "bin/**", "dist/**", "README.md", "LICENSE" diff --git a/packages/lsp-server/src/index.ts b/packages/lsp-server/src/index.ts index e11667e..d7605c7 100644 --- a/packages/lsp-server/src/index.ts +++ b/packages/lsp-server/src/index.ts @@ -1,4 +1,4 @@ -import { createConnection, ProposedFeatures } from 'vscode-languageserver/node'; +import { createConnection, ProposedFeatures } from 'vscode-languageserver/node.js'; import { registerHandlers } from './server.js'; import { startTcpServer } from './tcp-server.js'; diff --git a/packages/lsp-server/src/lockfile-writer.ts b/packages/lsp-server/src/lockfile-writer.ts index db5d74c..b9ad023 100644 --- a/packages/lsp-server/src/lockfile-writer.ts +++ b/packages/lsp-server/src/lockfile-writer.ts @@ -21,7 +21,10 @@ export function writeLspPort(lockPath: string, port: number): boolean { const updated = { ...existing, port_lsp: port }; const tempPath = `${lockPath}.lsp.tmp`; // Must be same directory as lockPath for atomic rename mkdirSync(dirname(lockPath), { recursive: true }); - writeFileSync(tempPath, JSON.stringify(updated, null, 2) + '\n', 'utf8'); + // mode 0o600 — daemon.lock carries the bearer token; rename preserves the + // temp file's permissions, so an unrestricted temp would undo the daemon's + // own permission hardening (lockfile.js writes it 0o600). + writeFileSync(tempPath, JSON.stringify(updated, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 }); renameSync(tempPath, lockPath); // atomic replace (lockfile.js lines 121-123 pattern) return true; } diff --git a/packages/lsp-server/src/server.ts b/packages/lsp-server/src/server.ts index d977607..8a86330 100644 --- a/packages/lsp-server/src/server.ts +++ b/packages/lsp-server/src/server.ts @@ -1,7 +1,7 @@ import { TextDocuments, TextDocumentSyncKind, type Connection, type InitializeResult, -} from 'vscode-languageserver/node'; +} from 'vscode-languageserver/node.js'; import { TextDocument } from 'vscode-languageserver-textdocument'; import type { LsPosition } from '@airtable-formula/language-services'; import { diff --git a/packages/lsp-server/src/tcp-server.ts b/packages/lsp-server/src/tcp-server.ts index 976a689..c88d6f2 100644 --- a/packages/lsp-server/src/tcp-server.ts +++ b/packages/lsp-server/src/tcp-server.ts @@ -1,7 +1,7 @@ import * as net from 'node:net'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { createConnection, ProposedFeatures } from 'vscode-languageserver/node'; +import { createConnection, ProposedFeatures } from 'vscode-languageserver/node.js'; import { StreamMessageReader, StreamMessageWriter } from 'vscode-jsonrpc/node'; import { registerHandlers } from './server.js'; import { writeLspPort } from './lockfile-writer.js'; diff --git a/packages/lsp-server/tsup.config.ts b/packages/lsp-server/tsup.config.ts index c0ae027..907ffa1 100644 --- a/packages/lsp-server/tsup.config.ts +++ b/packages/lsp-server/tsup.config.ts @@ -11,4 +11,9 @@ export default defineConfig({ dts: false, clean: true, outDir: 'dist', + // Bundled CJS deps call require('util') etc.; esbuild's ESM require-shim + // throws on Node built-ins unless a real require is in scope. + banner: { + js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);", + }, }); diff --git a/packages/mcp-server/src/cli.js b/packages/mcp-server/src/cli.js index 68cdb96..5557aff 100644 --- a/packages/mcp-server/src/cli.js +++ b/packages/mcp-server/src/cli.js @@ -261,7 +261,15 @@ export async function runCli(args) { if (subcmd === 'status') { const { getDaemonStatus } = await import('./daemon/launcher.js'); const status = await getDaemonStatus({ configDir: process.env.AIRTABLE_USER_MCP_HOME }); - process.stdout.write(JSON.stringify(status, null, 2) + '\n'); + // Redact the bearer token — `daemon status` output lands in shell + // history, terminal scrollback, and pasted bug reports. The token is + // readable from ~/.airtable-user-mcp/daemon.token when actually needed. + const redacted = JSON.stringify( + status, + (key, value) => (key === 'bearerToken' && typeof value === 'string' ? '[redacted]' : value), + 2, + ); + process.stdout.write(redacted + '\n'); return true; } process.stderr.write('Unknown daemon subcommand: ' + (subcmd ?? '(none)') + '\n'); diff --git a/packages/mcp-server/src/daemon/lockfile.js b/packages/mcp-server/src/daemon/lockfile.js index 50a68f0..3fce04f 100644 --- a/packages/mcp-server/src/daemon/lockfile.js +++ b/packages/mcp-server/src/daemon/lockfile.js @@ -1,6 +1,8 @@ -import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { getHomeDir } from '../paths.js'; +import { safeAtomicWriteFileSync } from '../safe-write.js'; +import { applyPrivatePermissions } from './token.js'; /** * @typedef {Object} DaemonLockRecord @@ -27,7 +29,9 @@ export function acquire(record, options = {}) { for (let attempt = 0; attempt < 2; attempt++) { let fd; try { - fd = openSync(lockPath, 'wx'); + // 0o600 — the lockfile carries the plaintext bearerToken; without an + // explicit mode it would be created world-readable (default 0o644). + fd = openSync(lockPath, 'wx', 0o600); } catch (error) { if (isExistsError(error)) { if (attempt === 0 && tryReclaimStale(lockPath)) { @@ -49,6 +53,7 @@ export function acquire(record, options = {}) { } } + applyPrivatePermissions(lockPath); return true; } @@ -117,9 +122,8 @@ export function replace(record, options = {}) { } mkdirSync(dirname(lockPath), { recursive: true }); - const tempPath = `${lockPath}.tmp`; - writeFileSync(tempPath, serialize(normalized), 'utf8'); - renameSync(tempPath, lockPath); + safeAtomicWriteFileSync(lockPath, serialize(normalized), { encoding: 'utf8', mode: 0o600 }); + applyPrivatePermissions(lockPath); return true; } diff --git a/packages/mcp-server/src/daemon/server.js b/packages/mcp-server/src/daemon/server.js index 125fd02..7aba285 100644 --- a/packages/mcp-server/src/daemon/server.js +++ b/packages/mcp-server/src/daemon/server.js @@ -1,5 +1,6 @@ import { createServer } from 'node:http'; import { createRequire } from 'node:module'; +import { timingSafeEqual } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; @@ -23,6 +24,7 @@ import { createNamedTunnel, writeTunnelConfig, readNamedTunnelConfig, + routeTunnelDns, } from './tunnel-providers/cloudflared-named-setup.js'; import { getTunnelBinaryPath } from './install-tunnel.js'; import { homedir } from 'node:os'; @@ -286,11 +288,21 @@ export async function startDaemonServer(options = {}) { } }; + // Constant-time comparison — `===` short-circuits on the first differing + // byte, which lets a tunnel-side attacker time their way through the token. + const tokensMatch = (provided, expected) => { + if (typeof provided !== 'string' || typeof expected !== 'string') return false; + const a = Buffer.from(provided, 'utf8'); + const b = Buffer.from(expected, 'utf8'); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); + }; + const requireBearer = (req, res, next) => { const header = req.headers?.authorization ?? ''; const match = header.match(/^Bearer\s+(.+)$/i); const provided = match ? match[1] : null; - if (provided !== currentToken.bearerToken) { + if (!tokensMatch(provided, currentToken.bearerToken)) { track401Burst(req); // 401-burst tripwire (D-06) const wantHtml = (req.headers?.accept ?? '').includes('text/html'); if (wantHtml) { @@ -450,10 +462,26 @@ export async function startDaemonServer(options = {}) { return; } - // Idempotent: if already configured, just return existing config + // Idempotent for the SAME hostname; a DIFFERENT hostname reconfigures + // the existing tunnel in place (route dns + rewrite managed YAML) — + // same uuid and credentials, no new tunnel. Without this branch a new + // hostname was silently ignored and the old one kept serving. const existing = readNamedTunnelConfig(options.configDir); if (existing) { - res.json({ ok: true, uuid: existing.uuid, hostname: existing.hostname, configPath: existing.configPath, alreadyConfigured: true }); + if (existing.hostname === hostname) { + res.json({ ok: true, uuid: existing.uuid, hostname: existing.hostname, configPath: existing.configPath, alreadyConfigured: true }); + return; + } + const binaryPath = getTunnelBinaryPath(options.configDir); + await routeTunnelDns({ configDir: options.configDir, uuid: existing.uuid, hostname, binaryPath }); + const rewritten = writeTunnelConfig({ + configDir: options.configDir, + uuid: existing.uuid, + hostname, + port: getBoundPort(httpServer), + credentialsPath: existing.credentialsPath, + }); + res.json({ ok: true, uuid: existing.uuid, hostname, configPath: rewritten.configPath, reconfigured: true }); return; } diff --git a/packages/mcp-server/src/daemon/token.js b/packages/mcp-server/src/daemon/token.js index 3388525..e17b1cb 100644 --- a/packages/mcp-server/src/daemon/token.js +++ b/packages/mcp-server/src/daemon/token.js @@ -101,7 +101,12 @@ function normalizeRecord(value) { }; } -function applyPrivatePermissions(tokenPath) { +/** + * Restrict a secret-bearing file to the current user. Best-effort and + * non-fatal on every platform — shared by daemon.token and daemon.lock, + * both of which hold the plaintext bearer token. + */ +export function applyPrivatePermissions(tokenPath) { if (process.platform === 'win32') { restrictWindowsAcl(tokenPath); } else { @@ -109,10 +114,17 @@ function applyPrivatePermissions(tokenPath) { } } +// icacls parses its `principal:permissions` argument itself; characters like +// `(`, `)`, `,`, `:` in an unsanitized USERNAME/USERDOMAIN could corrupt the +// grant (see extension/src/mcp/secure-permissions.ts for the same defense). +function sanitizeAclPart(value) { + return (value ?? '').replace(/[^A-Za-z0-9 ._-]/g, ''); +} + function restrictWindowsAcl(tokenPath) { try { - const username = process.env.USERNAME; - const domain = process.env.USERDOMAIN; + const username = sanitizeAclPart(process.env.USERNAME); + const domain = sanitizeAclPart(process.env.USERDOMAIN); const target = domain && username ? `${domain}\\${username}` : username; if (!target) throw new Error('Cannot resolve Windows username'); const result = spawnSync('icacls', [tokenPath, '/inheritance:r', '/grant:r', `${target}:(R,W)`], { diff --git a/packages/mcp-server/src/daemon/tunnel-providers/cloudflared-named-setup.js b/packages/mcp-server/src/daemon/tunnel-providers/cloudflared-named-setup.js index 20e1287..b62f919 100644 --- a/packages/mcp-server/src/daemon/tunnel-providers/cloudflared-named-setup.js +++ b/packages/mcp-server/src/daemon/tunnel-providers/cloudflared-named-setup.js @@ -11,7 +11,6 @@ */ import { - chmodSync, existsSync, mkdirSync, readFileSync, @@ -21,10 +20,10 @@ import { safeAtomicWriteFileSync } from '../../safe-write.js'; import { spawn as nodeSpawn } from 'node:child_process'; import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; -import { spawnSync } from 'node:child_process'; import { getTunnelBinaryPath } from '../install-tunnel.js'; import { getHomeDir } from '../../paths.js'; +import { applyPrivatePermissions } from '../token.js'; const CONFIG_FILENAME = 'cloudflared-named.yml'; const DEFAULT_LOGIN_TIMEOUT_MS = 10 * 60 * 1000; @@ -301,21 +300,55 @@ export async function createNamedTunnel(options) { return { uuid, name: parsedName || options.name, credentialsPath }; }) .then(async (tunnel) => { - const { code, stdout, stderr } = await runCapture( - spawnImpl, + await routeTunnelDns({ + configDir: options.configDir, + uuid: tunnel.uuid, + hostname: options.hostname, binaryPath, - ['tunnel', 'route', 'dns', tunnel.uuid, options.hostname], - options.signal, - ); - if (code !== 0) { - throw new Error( - `cloudflared route dns exited with code ${code}: ${stderr.trim() || stdout.trim()}`, - ); - } + signal: options.signal, + dependencies: options.dependencies, + }); return tunnel; }); } +/** + * Route an additional/replacement hostname to an EXISTING tunnel + * (`cloudflared tunnel route dns `). Used to reconfigure a + * named tunnel's hostname without creating a new tunnel — the uuid and + * credentials stay the same; only the DNS route (and our managed YAML, + * rewritten by the caller) change. + * + * @param {{ + * configDir?: string, + * uuid: string, + * hostname: string, + * binaryPath?: string, + * signal?: AbortSignal, + * dependencies?: { spawn?: typeof nodeSpawn }, + * }} options + * @returns {Promise} + */ +export async function routeTunnelDns(options) { + if (!options.uuid) throw new Error('routeTunnelDns: uuid is required.'); + if (!options.hostname) throw new Error('routeTunnelDns: hostname is required.'); + const binaryPath = options.binaryPath ?? getTunnelBinaryPath(options.configDir); + assertBinaryExists(binaryPath); + const spawnImpl = options.dependencies?.spawn ?? nodeSpawn; + + const { code, stdout, stderr } = await runCapture( + spawnImpl, + binaryPath, + ['tunnel', 'route', 'dns', options.uuid, options.hostname], + options.signal, + ); + if (code !== 0) { + throw new Error( + `cloudflared route dns exited with code ${code}: ${stderr.trim() || stdout.trim()}`, + ); + } +} + // ───────────────────────────────────────────────────────────────────── // cloudflared tunnel delete // ───────────────────────────────────────────────────────────────────── @@ -642,20 +675,7 @@ function unquoteYaml(value) { return value; } -/** - * @param {string} path - */ -function applyPrivatePermissions(path) { - if (process.platform === 'win32') { - const username = process.env.USERNAME; - const domain = process.env.USERDOMAIN; - const target = domain && username ? `${domain}\\${username}` : username ?? ''; - if (!target) return; - spawnSync('icacls', [path, '/inheritance:r', '/grant:r', `${target}:(R,W)`], { - encoding: 'utf8', - windowsHide: true, - }); - return; - } - chmodSync(path, 0o600); -} +// applyPrivatePermissions is imported from ../token.js — the shared helper +// sanitizes USERNAME/USERDOMAIN before building the icacls principal. Do not +// re-introduce a local copy here (a previous unsanitized duplicate was a +// security regression). diff --git a/packages/mcp-server/src/login-runner.js b/packages/mcp-server/src/login-runner.js index 21c582b..7b6f7dd 100644 --- a/packages/mcp-server/src/login-runner.js +++ b/packages/mcp-server/src/login-runner.js @@ -3,14 +3,20 @@ * Programmatic login runner for the VS Code extension. * * Same flow as login.js but designed for non-interactive use: - * - Reads credentials from environment variables only (no CLI args for security) + * - Receives credentials over the fork() IPC channel when spawned by the + * extension (preferred — keeps secrets out of the child environment), + * falling back to environment variables for standalone use * - Outputs structured JSON to stdout * - Uses exit codes for success/failure * + * IPC protocol (when process.send is available): + * child → parent: { type: 'request-credentials' } + * parent → child: { type: 'credentials', email, password, otpSecret } + * * Environment variables: - * AIRTABLE_EMAIL — (required) Airtable account email - * AIRTABLE_PASSWORD — (required) Airtable account password - * AIRTABLE_OTP_SECRET — (optional) TOTP 2FA base32 secret + * AIRTABLE_EMAIL — (fallback) Airtable account email + * AIRTABLE_PASSWORD — (fallback) Airtable account password + * AIRTABLE_OTP_SECRET — (optional fallback) TOTP 2FA base32 secret * AIRTABLE_PROFILE — (optional) profile dir name (default: .chrome-profile) * AIRTABLE_BROWSER_CHANNEL — (optional) patchright channel (chrome|msedge|chromium) * AIRTABLE_BROWSER_PATH — (optional) absolute path to browser executable @@ -68,17 +74,65 @@ function generateTOTP(secretBase32) { return totp.generate(); } +/** + * Ask the parent process for credentials over the IPC channel. + * Resolves null when no parent answers within the timeout (standalone run, + * or a parent that doesn't speak the protocol) — caller falls back to env. + */ +function requestCredentialsOverIpc(timeoutMs = 10_000) { + return new Promise((resolve) => { + const onMessage = (msg) => { + if (msg && msg.type === 'credentials') { + cleanup(); + resolve({ + email: msg.email || null, + password: msg.password || null, + otpSecret: msg.otpSecret || null, + }); + } + }; + const timer = setTimeout(() => { + cleanup(); + resolve(null); + }, timeoutMs); + const cleanup = () => { + clearTimeout(timer); + process.removeListener('message', onMessage); + }; + process.on('message', onMessage); + try { + process.send({ type: 'request-credentials' }); + } catch { + cleanup(); + resolve(null); + } + }); +} + async function main() { - const email = process.env.AIRTABLE_EMAIL; - const password = process.env.AIRTABLE_PASSWORD; - const otpSecret = process.env.AIRTABLE_OTP_SECRET || null; + let email = process.env.AIRTABLE_EMAIL; + let password = process.env.AIRTABLE_PASSWORD; + let otpSecret = process.env.AIRTABLE_OTP_SECRET || null; + + // IPC-first: when spawned via fork() the parent holds the credentials and + // sends them on request, so they never enter this process's environment. + if ((!email || !password) && typeof process.send === 'function') { + console.error('[login-runner] Requesting credentials over IPC...'); + const ipcCreds = await requestCredentialsOverIpc(); + if (ipcCreds) { + email = ipcCreds.email || email; + password = ipcCreds.password || password; + otpSecret = ipcCreds.otpSecret || otpSecret; + } + } + const { getProfileDir } = await import('./paths.js'); const profileDir = getProfileDir(); const browserChannel = process.env.AIRTABLE_BROWSER_CHANNEL || 'chrome'; const browserPath = process.env.AIRTABLE_BROWSER_PATH || undefined; if (!email || !password) { - output({ ok: false, error: 'AIRTABLE_EMAIL and AIRTABLE_PASSWORD environment variables are required' }); + output({ ok: false, error: 'Credentials required: send them over IPC (fork) or set AIRTABLE_EMAIL and AIRTABLE_PASSWORD' }); process.exit(1); } diff --git a/packages/mcp-server/src/tool-config.js b/packages/mcp-server/src/tool-config.js index 54e5b05..4be83c4 100644 --- a/packages/mcp-server/src/tool-config.js +++ b/packages/mcp-server/src/tool-config.js @@ -209,7 +209,9 @@ export class ToolConfigManager { const tmp = `${configFile}.${randomBytes(6).toString('hex')}.tmp`; try { try { - await writeFile(tmp, JSON.stringify(this._config, null, 2), 'utf8'); + // 0o600 — this file gates which (potentially destructive) tools are + // exposed; don't leave it writable through lax default permissions. + await writeFile(tmp, JSON.stringify(this._config, null, 2), { encoding: 'utf8', mode: 0o600 }); await rename(tmp, configFile); } catch (err) { // Best-effort cleanup — rename may have happened before a later failure @@ -268,8 +270,17 @@ export class ToolConfigManager { const def = BUILTIN_PROFILES[profile]; if (!def) { - // Unknown profile → fall back to full - return new Set(Object.keys(TOOL_CATEGORIES)); + // Unknown profile → fail CLOSED to read-only. switchProfile() validates + // names, so this only happens when tools-config.json was hand-edited or + // tampered with — silently exposing destructive tools in that state + // would defeat the gating the user configured. + console.error(`[tool-config] Unknown activeProfile "${profile}" — failing closed to read-only`); + const readOnly = new Set(BUILTIN_PROFILES['read-only'].categories); + const enabled = new Set(); + for (const [tool, category] of Object.entries(TOOL_CATEGORIES)) { + if (readOnly.has(category)) enabled.add(tool); + } + return enabled; } const cats = new Set(def.categories); diff --git a/packages/mcp-server/test/test-tool-config.test.js b/packages/mcp-server/test/test-tool-config.test.js index eed41d2..1b034fe 100644 --- a/packages/mcp-server/test/test-tool-config.test.js +++ b/packages/mcp-server/test/test-tool-config.test.js @@ -126,6 +126,19 @@ describe('ToolConfigManager', () => { const enabled = mgr.enabledToolNames(); assert.equal(enabled.size, 66); }); + + it('unknown profile fails closed to read-only', async () => { + // switchProfile() rejects unknown names, so simulate a hand-edited / + // tampered tools-config.json by mutating loaded state directly. + mgr._config.activeProfile = 'totally-bogus'; + const enabled = mgr.enabledToolNames(); + assert.equal(enabled.size, 12, 'must match the read-only tool count'); + assert.ok(enabled.has('get_base_schema')); + assert.ok(!enabled.has('delete_table')); + assert.ok(!enabled.has('delete_field')); + assert.ok(!enabled.has('create_table')); + await mgr.switchProfile('full'); + }); }); describe('switchProfile()', () => { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index fd2d54e..9e3920b 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -169,6 +169,10 @@ export interface TunnelState { provider: TunnelProviderId; ngrokAuthtokenSet: boolean; // true when VS Code SecretStorage has a token for 'airtable-formula.ngrok.authtoken' autoDisabledReason: TunnelAutoDisabledReason | null; + /** Hostname from an existing cloudflared-named.yml, or null when no named + * tunnel has been configured yet. Lets the UI show what "reuse the + * already-configured tunnel" actually points at. */ + namedTunnelHostname?: string | null; } export interface DaemonStatusInfo { diff --git a/packages/webview/src/components/IdeCard.tsx b/packages/webview/src/components/IdeCard.tsx index 2573169..cc25339 100644 --- a/packages/webview/src/components/IdeCard.tsx +++ b/packages/webview/src/components/IdeCard.tsx @@ -35,8 +35,8 @@ function LspBadge({ active = true }: { active?: boolean }) { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.58rem', fontWeight: 700, fontFamily: 'var(--font-mono)', color: active ? 'var(--fg-ok)' : 'var(--fg-err)', - background: active ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.1)', - border: `1px solid ${active ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`, + background: active ? 'var(--bg-lsp-ok)' : 'var(--bg-lsp-err)', + border: `1px solid ${active ? 'var(--border-lsp-ok)' : 'var(--border-lsp-err)'}`, borderRadius: 3, padding: '0 4px', lineHeight: '16px', flexShrink: 0, }}>LSP ); @@ -101,7 +101,7 @@ export function IdeCard({ status, onSetup, onUnconfigure, loading }: IdeCardProp MCP {status.mcpConfigured ? 'configured' : 'not configured'} - {status.mcpConfigured ? 'ready' : 'missing'} + {status.mcpConfigured ? 'Ready' : 'Missing'} )} @@ -113,7 +113,7 @@ export function IdeCard({ status, onSetup, onUnconfigure, loading }: IdeCardProp Formula · Script · Automation - via extension + Via extension )} @@ -125,7 +125,7 @@ export function IdeCard({ status, onSetup, onUnconfigure, loading }: IdeCardProp LSP {status.lspConfigured ? 'configured' : 'not configured'} - {status.lspConfigured ? 'ready' : 'missing'} + {status.lspConfigured ? 'Ready' : 'Missing'} )} @@ -138,7 +138,7 @@ export function IdeCard({ status, onSetup, onUnconfigure, loading }: IdeCardProp LSP {status.lspConfigured ? 'configured' : 'not configured'} - {status.lspConfigured ? 'ready' : 'missing'} + {status.lspConfigured ? 'Ready' : 'Missing'} )} diff --git a/packages/webview/src/lib/friendlyError.ts b/packages/webview/src/lib/friendlyError.ts new file mode 100644 index 0000000..b25dcfe --- /dev/null +++ b/packages/webview/src/lib/friendlyError.ts @@ -0,0 +1,72 @@ +/** + * Map raw error strings (Node errno codes, HTTP statuses, patchright + * internals) to human-readable guidance. The raw text stays available for + * tooltips / bug reports; users see what happened and what to do next. + */ +export interface FriendlyError { + message: string; + hint?: string; + raw: string; +} + +const PATTERNS: Array<{ test: RegExp; message: string; hint?: string }> = [ + { + test: /ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|fetch failed|network/i, + message: 'Network error while contacting the service.', + hint: 'Check your internet connection (or proxy/VPN) and try again.', + }, + { + test: /401|Unauthorized/i, + message: 'Authentication was rejected.', + hint: 'Your Airtable session may have expired — try logging in again.', + }, + { + test: /403|Forbidden/i, + message: 'Access denied by Airtable.', + hint: 'Your account may lack permission for this base or action.', + }, + { + test: /429|rate.?limit/i, + message: 'Airtable is rate-limiting requests.', + hint: 'Wait a minute and try again.', + }, + { + test: /exit code 21|launchPersistentContext|Target page, context or browser has been closed/i, + message: 'The browser could not start (its profile may be locked).', + hint: 'Close any leftover Chrome windows from a previous login and retry.', + }, + { + test: /No supported browser|executable doesn't exist|chrome-missing/i, + message: 'No usable browser was found.', + hint: 'Install Google Chrome, or use "Download bundled Chromium" below.', + }, + { + test: /ENOENT/i, + message: 'A required file or folder is missing.', + hint: 'Try running Setup again to recreate the configuration.', + }, + { + test: /EACCES|EPERM/i, + message: 'Permission denied while accessing a file.', + hint: 'Another program may be locking it, or it needs elevated rights.', + }, + { + test: /ENOSPC/i, + message: 'The disk is full.', + hint: 'Free up disk space and retry.', + }, + { + test: /timed? ?out/i, + message: 'The operation timed out.', + hint: 'The service may be slow right now — try again.', + }, +]; + +export function friendlyError(raw: string | null | undefined): FriendlyError | null { + if (!raw) return null; + for (const p of PATTERNS) { + if (p.test.test(raw)) return { message: p.message, hint: p.hint, raw }; + } + // Unrecognized — show the raw text but keep it as the message so nothing is hidden. + return { message: raw, raw }; +} diff --git a/packages/webview/src/lib/vscode.ts b/packages/webview/src/lib/vscode.ts index d329bdd..8c75651 100644 --- a/packages/webview/src/lib/vscode.ts +++ b/packages/webview/src/lib/vscode.ts @@ -8,11 +8,19 @@ declare function acquireVsCodeApi(): { const vscodeApi = (() => { try { return acquireVsCodeApi(); } - catch { return null; } + catch { + // Expected in the browser dev preview (vite dev); fatal inside VS Code. + console.warn('[webview] acquireVsCodeApi unavailable — messages to the extension will be dropped'); + return null; + } })(); export function sendToExtension(msg: WebviewMessage): void { - vscodeApi?.postMessage(msg); + if (!vscodeApi) { + console.warn('[webview] dropped message (no VS Code API):', msg.type); + return; + } + vscodeApi.postMessage(msg); } export function onExtensionMessage(handler: (msg: ExtensionMessage) => void): () => void { diff --git a/packages/webview/src/store.ts b/packages/webview/src/store.ts index 987732f..bafc63a 100644 --- a/packages/webview/src/store.ts +++ b/packages/webview/src/store.ts @@ -7,6 +7,10 @@ interface Store extends DashboardState { activeTab: 'overview' | 'setup' | 'prompts' | 'settings'; pendingActions: Set; pendingIdeActions: Map; // ideId → actionId + /** Daemon start/stop/restart in flight — daemon controls disable on THIS, + * not on the global pendingActions, so an unrelated slow action (e.g. an + * open file dialog) can't lock the user out of stopping the daemon. */ + pendingDaemonActions: Set; setTab: (tab: Store['activeTab']) => void; applyState: (state: DashboardState) => void; applyAuthState: (state: AuthState) => void; @@ -42,9 +46,14 @@ interface Store extends DashboardState { copyAirtablePat: () => void; configureOfficialAirtable: (ideId: import('@shared/types.js').IdeId) => void; unconfigureOfficialAirtable: (ideId: import('@shared/types.js').IdeId) => void; - savePrompt: (prompt: PromptDef) => void; - deletePrompt: (name: string) => void; - resetPrompt: (name: string) => void; + /** Prompt actions return their action id so callers can await completion + * (via pendingActions + consumeActionResult) before navigating away. */ + savePrompt: (prompt: PromptDef) => string; + deletePrompt: (name: string) => string; + resetPrompt: (name: string) => string; + /** Read-and-clear the success flag recorded by markActionDone. Undefined + * when no result was recorded (e.g. id unknown). */ + consumeActionResult: (id: string) => boolean | undefined; } const defaultSettings: SettingsSnapshot = { @@ -77,7 +86,34 @@ const defaultAuth: AuthState = { hasCredentials: false, }; -export const useStore = create((set, get) => ({ +// If the extension never answers (host crash, lost message, webview reload), +// pending actions must not keep buttons disabled forever — auto-expire them. +const PENDING_TIMEOUT_MS = 60_000; +const pendingTimers = new Map>(); + +// Per-action outcomes recorded by markActionDone, consumed by components that +// wait for confirmation before navigating (PromptEditor). Bounded — unclaimed +// results from fire-and-forget actions are evicted oldest-first. +const actionResults = new Map(); +const ACTION_RESULTS_MAX = 50; + +export const useStore = create((set, get) => { + /** Track an in-flight action and schedule its auto-expiry. */ + const beginAction = (id: string, timeoutMs = PENDING_TIMEOUT_MS) => { + set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + pendingTimers.set(id, setTimeout(() => { + pendingTimers.delete(id); + get().markActionDone(id, false); + }, timeoutMs)); + }; + + /** beginAction + membership in the daemon-specific pending set. */ + const beginDaemonAction = (id: string, timeoutMs?: number) => { + beginAction(id, timeoutMs); + set(s => ({ pendingDaemonActions: new Set([...s.pendingDaemonActions, id]) })); + }; + + return ({ ideStatuses: [], versions: { extension: '—', mcpServerBundled: '—' }, aiFilesCount: 0, @@ -87,6 +123,7 @@ export const useStore = create((set, get) => ({ activeTab: 'overview', pendingActions: new Set(), pendingIdeActions: new Map(), + pendingDaemonActions: new Set(), setTab: (tab) => set({ activeTab: tab }), applyState: (state) => set(s => { @@ -111,213 +148,243 @@ export const useStore = create((set, get) => ({ setupIde: (ideId) => { const id = randomId(); + beginAction(id); set(s => { - const nextPending = new Set([...s.pendingActions, id]); const nextIde = new Map(s.pendingIdeActions); nextIde.set(ideId, id); - return { pendingActions: nextPending, pendingIdeActions: nextIde }; + return { pendingIdeActions: nextIde }; }); sendToExtension({ type: 'action:setupIde', id, ideId: ideId as any }); }, setupAll: () => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'action:setupAll', id }); }, refresh: () => { const id = randomId(); + beginAction(id); sendToExtension({ type: 'action:refresh', id }); }, login: () => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + // Auto-login drives a real browser — can legitimately take minutes. + beginAction(id, 360_000); sendToExtension({ type: 'action:login', id }); }, logout: () => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'action:logout', id }); }, status: () => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'action:status', id }); }, saveCredentials: (email, password, otpSecret) => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'action:saveCredentials', id, email, password, otpSecret }); }, installBrowser: () => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + // Chromium download can take minutes on slow connections. + beginAction(id, 600_000); sendToExtension({ type: 'action:install-browser', id }); }, removeBrowser: () => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'action:removeBrowser', id }); }, unconfigureIde: (ideId) => { const id = randomId(); + beginAction(id); set(s => { - const nextPending = new Set([...s.pendingActions, id]); const nextIde = new Map(s.pendingIdeActions); nextIde.set(ideId, id); - return { pendingActions: nextPending, pendingIdeActions: nextIde }; + return { pendingIdeActions: nextIde }; }); sendToExtension({ type: 'action:unconfigureIde', id, ideId: ideId as any }); }, debugStartSession: () => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'action:debug.startSession', id }); }, debugStopAndExport: () => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'action:debug.stopAndExport', id }); }, debugExport: () => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'action:debug.export', id }); }, manualLogin: () => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + // Manual login waits for the user to finish in the browser (up to ~5.5m). + beginAction(id, 360_000); sendToExtension({ type: 'action:manualLogin', id }); }, backupSession: () => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'action:backupSession', id }); }, restoreSession: () => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'action:restoreSession', id }); }, selectCustomBrowser: () => { const id = randomId(); + // Long timeout — the native file dialog stays open until the user acts. + beginAction(id, 600_000); sendToExtension({ type: 'action:selectCustomBrowser', id }); }, setBrowserChoice: (choice) => { const id = randomId(); + beginAction(id); sendToExtension({ type: 'action:setBrowserChoice', id, choice }); }, openStoragePath: (p) => { const id = randomId(); + beginAction(id); sendToExtension({ type: 'action:openStoragePath', id, path: p }); }, enableTunnel: (provider, authtoken, domain) => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'tunnel:enable', id, provider, authtoken, domain }); }, disableTunnel: () => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'tunnel:disable', id }); }, setNgrokAuthtoken: (authtoken) => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'tunnel:set-ngrok-authtoken', id, authtoken }); }, startDaemon: () => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginDaemonAction(id); sendToExtension({ type: 'daemon:start', id }); }, stopDaemon: () => { + // Graceful wait (10s) + kill escalation (3s) can exceed the default expiry. const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginDaemonAction(id, 30_000); sendToExtension({ type: 'daemon:stop', id }); }, restartDaemon: () => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginDaemonAction(id, 45_000); sendToExtension({ type: 'daemon:restart', id }); }, copyBearerToken: () => { const id = randomId(); + beginAction(id); sendToExtension({ type: 'daemon:copy-bearer-token', id }); }, rotateToken: () => { + // Daemon-scoped: rotating invalidates connected clients, so daemon + // controls should reflect the in-flight rotation too. const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginDaemonAction(id); sendToExtension({ type: 'daemon:rotate-token', id }); }, saveAirtablePat: (pat) => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'action:save-airtable-pat', id, pat }); }, copyAirtablePat: () => { const id = randomId(); + beginAction(id); sendToExtension({ type: 'action:copy-airtable-pat', id }); }, configureOfficialAirtable: (ideId) => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'action:configure-official-airtable', id, ideId }); }, unconfigureOfficialAirtable: (ideId) => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'action:unconfigure-official-airtable', id, ideId }); }, savePrompt: (prompt) => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'action:save-prompt', id, prompt }); + return id; }, deletePrompt: (name) => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'action:delete-prompt', id, name }); + return id; }, resetPrompt: (name) => { const id = randomId(); - set(s => ({ pendingActions: new Set([...s.pendingActions, id]) })); + beginAction(id); sendToExtension({ type: 'action:reset-prompt', id, name }); + return id; + }, + + consumeActionResult: (id) => { + const result = actionResults.get(id); + actionResults.delete(id); + return result; }, - markActionDone: (id, _ok) => { + markActionDone: (id, ok) => { + const timer = pendingTimers.get(id); + if (timer) { clearTimeout(timer); pendingTimers.delete(id); } + actionResults.set(id, ok); + while (actionResults.size > ACTION_RESULTS_MAX) { + const oldest = actionResults.keys().next().value; + if (oldest === undefined) break; + actionResults.delete(oldest); + } set(s => { const next = new Set(s.pendingActions); next.delete(id); @@ -325,7 +392,10 @@ export const useStore = create((set, get) => ({ for (const [ideId, actionId] of nextIde) { if (actionId === id) { nextIde.delete(ideId); break; } } - return { pendingActions: next, pendingIdeActions: nextIde }; + const nextDaemon = new Set(s.pendingDaemonActions); + nextDaemon.delete(id); + return { pendingActions: next, pendingIdeActions: nextIde, pendingDaemonActions: nextDaemon }; }); }, -})); + }); +}); diff --git a/packages/webview/src/styles.css b/packages/webview/src/styles.css index 072a216..73d1dff 100644 --- a/packages/webview/src/styles.css +++ b/packages/webview/src/styles.css @@ -7,6 +7,9 @@ --at-gray700: rgb(49,53,62); --at-gray600: rgb(65,69,77); --at-gray500: rgb(97,102,112); + /* gray450: between gray500/gray400 — gray500 on --bg is 2.86:1, failing + WCAG AA (4.5:1) for the small muted text it backs; this hits ~4.8:1. */ + --at-gray450: rgb(133,139,150); --at-gray400: rgb(151,154,160); --at-blue: rgb(22,110,225); --at-blueLight1: rgb(160,198,255); @@ -35,10 +38,18 @@ --border: rgba(255,255,255,0.10); --border-em: rgba(255,255,255,0.25); --border-focus: var(--at-blue); + --border-error: rgba(220,4,59,0.25); + --border-warn: rgba(255,186,5,0.25); + + /* LSP status badge fills (IdeCard) — derived from --at-green / --at-red */ + --bg-lsp-ok: rgba(34,197,94,0.12); + --bg-lsp-err: rgba(239,68,68,0.10); + --border-lsp-ok: rgba(34,197,94,0.3); + --border-lsp-err: rgba(239,68,68,0.25); --fg: #ffffff; --fg-subtle: var(--at-gray400); - --fg-muted: var(--at-gray500); + --fg-muted: var(--at-gray450); --fg-ai: var(--at-pinkLight1); --fg-info: var(--at-blueLight1); --fg-ok: var(--at-greenLight1); @@ -65,9 +76,44 @@ body { -webkit-font-smoothing: antialiased; } button { font: inherit; cursor: pointer; border: none; background: none; } +/* Keyboard focus must be visible on every interactive control, including + icon-only buttons that carry no .btn/.action-card class. */ +button:focus-visible, +.input-field:focus-visible, +select:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 2px; +} +button:disabled, +button[aria-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; +} +.input-field:focus-visible { border-color: var(--at-blue); } /* ─── Design System Classes ─── */ +/* Small uppercase form/section labels — one source of truth for the + fontSize/tracking so design changes don't require editing every tab. */ +.uppercase-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +/* Skeleton loading rows (Setup tab while IDE detection runs) */ +.skeleton-row { + height: 38px; + border-radius: 10px; + background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.09) 50%, rgba(255,255,255,0.04) 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.4s ease-in-out infinite; +} +@keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + /* Glass Panel */ .glass-panel { border: 1px solid var(--border); diff --git a/packages/webview/src/tabs/Overview.tsx b/packages/webview/src/tabs/Overview.tsx index f374c23..71e8861 100644 --- a/packages/webview/src/tabs/Overview.tsx +++ b/packages/webview/src/tabs/Overview.tsx @@ -79,7 +79,7 @@ export function Overview() { Extension {versions.extension} MCP {versions.mcpServerBundled} bundled {versions.mcpServerPublished && ( - ↑ update: {versions.mcpServerPublished} + ↑ update: {versions.mcpServerPublished} )} diff --git a/packages/webview/src/tabs/Prompts.tsx b/packages/webview/src/tabs/Prompts.tsx index 7b900e4..55fc8e5 100644 --- a/packages/webview/src/tabs/Prompts.tsx +++ b/packages/webview/src/tabs/Prompts.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useStore } from '../store.js'; import type { PromptDef, PromptArg } from '@shared/types.js'; import { Plus, ArrowLeft, Trash2, RotateCcw, Save, X, Info } from 'lucide-react'; @@ -85,31 +85,50 @@ function PromptEditor({ isNew: boolean; onBack: () => void; }) { - const { savePrompt, deletePrompt, resetPrompt } = useStore(); + const { savePrompt, deletePrompt, resetPrompt, pendingActions, consumeActionResult } = useStore(); const [name, setName] = useState(initial.name); const [desc, setDesc] = useState(initial.description); const [args, setArgs] = useState(initial.arguments); const [template, setTemplate] = useState(initial.template); const [dirty, setDirty] = useState(isNew); + // The action we are waiting on — navigation back happens only once the + // extension confirms it (action:result), and a failure keeps the editor + // open with the user's input intact instead of silently discarding it. + const [inFlightId, setInFlightId] = useState(null); + const [actionError, setActionError] = useState(null); + const busy = inFlightId !== null || pendingActions.size > 0; const nameIsValid = /^[a-z][a-z0-9-]*$/.test(name); + useEffect(() => { + if (!inFlightId || pendingActions.has(inFlightId)) return; + const ok = consumeActionResult(inFlightId); + setInFlightId(null); + if (ok === false) { + setActionError('The change did not apply — check the VS Code notifications for details, then try again.'); + } else { + onBack(); + } + }, [pendingActions, inFlightId, consumeActionResult, onBack]); + function markDirty() { setDirty(true); } function handleSave() { - if (!dirty || !nameIsValid) return; - savePrompt({ name, description: desc, arguments: args, template, isBuiltin: initial.isBuiltin, isModified: initial.isBuiltin }); - onBack(); + if (!dirty || !nameIsValid || inFlightId) return; + setActionError(null); + setInFlightId(savePrompt({ name, description: desc, arguments: args, template, isBuiltin: initial.isBuiltin, isModified: initial.isBuiltin })); } function handleDelete() { - deletePrompt(initial.name); - onBack(); + if (inFlightId) return; + setActionError(null); + setInFlightId(deletePrompt(initial.name)); } function handleReset() { - resetPrompt(initial.name); - onBack(); + if (inFlightId) return; + setActionError(null); + setInFlightId(resetPrompt(initial.name)); } function addArg() { @@ -217,25 +236,33 @@ function PromptEditor({ /> + {/* Action failure — stay on the editor so the user's input is kept */} + {actionError && ( +
+ {actionError} +
+ )} + {/* Actions */}
{initial.isBuiltin && initial.isModified && ( - )} {!initial.isBuiltin && ( - )} diff --git a/packages/webview/src/tabs/Settings.tsx b/packages/webview/src/tabs/Settings.tsx index 0720604..d1c9fdc 100644 --- a/packages/webview/src/tabs/Settings.tsx +++ b/packages/webview/src/tabs/Settings.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useStore } from '../store.js'; import { sendToExtension } from '../lib/vscode.js'; +import { friendlyError } from '../lib/friendlyError.js'; import { StatusDot } from '../components/StatusDot.js'; import { LogIn, LogOut, RefreshCw, Shield, Key, Clock, Globe, AlertTriangle, Download, Trash2, Sliders, FileJson, FolderOpen, ChevronDown, ChevronRight, Archive, Upload } from 'lucide-react'; @@ -89,11 +90,16 @@ export function Settings() { sendToExtension({ type: 'setting:change', key: 'auth.refreshIntervalHours', value: Number(e.target.value) }); }; + const resetCredsForm = () => { + setEmail(''); + setPassword(''); + setOtpSecret(''); + }; + const handleSaveCredentials = () => { if (!email || !password) return; saveCredentials(email, password, otpSecret); - setPassword(''); - setOtpSecret(''); + resetCredsForm(); setShowCreds(false); }; @@ -131,7 +137,14 @@ export function Settings() {
Manual Auto @@ -216,7 +229,7 @@ export function Settings() { )} {chromeMissing && ( -
+
@@ -261,11 +274,17 @@ export function Settings() {
)} - {downloadError && ( -
- Download failed: {dl?.error} -
- )} + {downloadError && (() => { + const fe = friendlyError(dl?.error); + return ( +
+ Download failed: {fe?.message} + {fe?.hint && ( + {fe.hint} + )} +
+ ); + })()}
)} @@ -306,11 +325,16 @@ export function Settings() { setOtpSecret(e.target.value)} style={{ fontSize: '0.75rem', padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--bg-input)', color: 'var(--fg)' }} /> +
+ 2FA secret: the base32 code Airtable shows when you set up an authenticator + app (also called a TOTP secret). Leave empty if 2FA is disabled. +
- {auth.error && ( -
- {auth.error} -
- )} + {auth.error && (() => { + const fe = friendlyError(auth.error); + return ( +
+ {fe?.message} + {fe?.hint && ( + {fe.hint} + )} +
+ ); + })()} {(auth.lastChecked || auth.lastLogin) && (
diff --git a/packages/webview/src/tabs/Setup.tsx b/packages/webview/src/tabs/Setup.tsx index c1a252d..d6f4263 100644 --- a/packages/webview/src/tabs/Setup.tsx +++ b/packages/webview/src/tabs/Setup.tsx @@ -225,7 +225,7 @@ const LSP_VARIANT_TABS = [ ] as const; export function Setup() { - const { ideStatuses, pendingActions, pendingIdeActions, setupIde, setupAll, unconfigureIde, tunnel, enableTunnel, disableTunnel, daemon, startDaemon, stopDaemon, restartDaemon, copyBearerToken, rotateToken, officialAirtable, saveAirtablePat, copyAirtablePat, configureOfficialAirtable, unconfigureOfficialAirtable } = useStore(); + const { ideStatuses, pendingActions, pendingIdeActions, pendingDaemonActions, setupIde, setupAll, unconfigureIde, tunnel, enableTunnel, disableTunnel, setNgrokAuthtoken, daemon, startDaemon, stopDaemon, restartDaemon, copyBearerToken, rotateToken, officialAirtable, saveAirtablePat, copyAirtablePat, configureOfficialAirtable, unconfigureOfficialAirtable } = useStore(); const LSP_EDITOR_IDS = new Set(['zed', 'helix', 'neovim']); const detected = ideStatuses.filter(ide => ide.detected); @@ -236,13 +236,20 @@ export function Setup() { LSP_EDITOR_IDS.has(ide.ideId) ? !ide.lspConfigured : !ide.mcpConfigured ); const isLoading = pendingActions.size > 0; + // Daemon controls disable on daemon-specific pending state only — an + // unrelated slow action (open file dialog, long install) must not lock the + // user out of stopping the daemon. + const daemonBusy = pendingDaemonActions.size > 0 || !!daemon?.starting; // Derive tunnel pending state from store pendingActions (consistent with IDE actions) const isTunnelPending = pendingActions.size > 0; const [selectedProvider, setSelectedProvider] = React.useState<'cf-quick' | 'ngrok' | 'cf-named'>('cf-quick'); const [ngrokAuthtokenInput, setNgrokAuthtokenInput] = React.useState(''); const [ngrokDomainInput, setNgrokDomainInput] = React.useState(''); + const [namedHostnameInput, setNamedHostnameInput] = React.useState(''); + const [editNgrokToken, setEditNgrokToken] = React.useState(false); const [copiedUrl, setCopiedUrl] = React.useState(false); + const [copiedDaemonUrl, setCopiedDaemonUrl] = React.useState(false); const [copiedToken, setCopiedToken] = React.useState(false); const [rotatedToken, setRotatedToken] = React.useState(false); const [patInput, setPatInput] = React.useState(''); @@ -273,7 +280,11 @@ export function Setup() { const authtoken = (selectedProvider === 'ngrok' && ngrokAuthtokenInput) ? ngrokAuthtokenInput : undefined; - enableTunnel(selectedProvider, authtoken, ngrokDomainInput || undefined); + const domain = + selectedProvider === 'cf-named' ? (namedHostnameInput.trim() || undefined) + : selectedProvider === 'ngrok' ? (ngrokDomainInput.trim() || undefined) + : undefined; + enableTunnel(selectedProvider, authtoken, domain); }; const handleDisableTunnel = () => { @@ -365,14 +376,31 @@ export function Setup() { )} {daemon.tunnelUrl && ( -
- Tunnel URL - +
+ Tunnel URL + {daemon.tunnelUrl} +
)} @@ -388,6 +416,7 @@ export function Setup() { ) : ( <> - - )} @@ -465,7 +496,7 @@ export function Setup() { {/* Provider picker */}
-