diff --git a/README.md b/README.md index 91255f6..0260b1d 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,8 @@ tailscale serve --bg 3456 clawtap cert ``` +If you're using a self-signed certificate, open `https://:3456/cert` on your phone to download and trust it directly (iOS: Install Profile → enable full trust in Certificate Trust Settings; Android: Settings → Security → Install certificate → CA certificate) — no need to AirDrop/email it manually. + **2. Install PWA:** Open the URL in Safari → Share → **Add to Home Screen**. **3. Enable notifications:** On first login in standalone mode, ClawTap automatically prompts for notification permission. You can also toggle notifications manually in **Settings**. @@ -266,6 +268,7 @@ SQLite with WAL mode. Stores review history, push subscriptions, rate limiting, | Stale sessions | `tmux kill-session -t clawtap` | | Port in use | `lsof -i :3456` then `kill ` | | Hooks not cleaned after crash | `clawtap hooks uninstall` or `clawtap stop` | +| `ERR_SSL_PROTOCOL_ERROR` | Server is on HTTP — use `http://` URL, or run `clawtap cert` to enable HTTPS | | Push notifications not working | Ensure HTTPS + PWA installed from home screen | | Adapter not showing | Check that the CLI is installed: `which claude`, `which codex`, `which gemini` | | Gemini hooks failing | Verify timeout is in milliseconds (5000, not 5) | diff --git a/bin/clawtap b/bin/clawtap index 43d63d2..3add429 100755 --- a/bin/clawtap +++ b/bin/clawtap @@ -75,6 +75,7 @@ Commands: hooks install Install hooks (all adapters, or use --adapter) hooks uninstall Remove hooks (all adapters, or use --adapter) cert Generate self-signed HTTPS certificate + reset-login Clear too-many-login-attempts lockout Options: -v, --version Show version @@ -152,7 +153,7 @@ HELP -out "$CERT_FILE" \ -days 365 \ -subj "/CN=ClawTap" \ - -addext "subjectAltName=IP:$(ipconfig getifaddr en0 2>/dev/null || echo '0.0.0.0')" \ + -addext "subjectAltName=IP:$(hostname -I 2>/dev/null | awk '{print $1}' || ipconfig getifaddr en0 2>/dev/null || echo '0.0.0.0')" \ 2>/dev/null if [ $? -ne 0 ]; then echo "Failed to generate certificate. Is openssl installed?" @@ -254,6 +255,14 @@ require_auth() { AUTH_TOKEN="" } +# "reset-login" → clear login rate-limit lockout +if [ "$1" = "reset-login" ]; then + curl -s $CURL_OPTS -X POST "$PROTOCOL://localhost:$PORT/api/auth/reset-attempts" \ + -H "Content-Type: application/json" -d '{}' >/dev/null + echo "Login rate limit reset." + exit 0 +fi + # No args → just start server, print URLs, exit if [ $# -eq 0 ]; then LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || echo "") diff --git a/package.json b/package.json index 682d297..5f0d5d9 100644 --- a/package.json +++ b/package.json @@ -79,5 +79,11 @@ "typescript": "^5.8.3", "vite": "^6.3.2", "vite-plugin-pwa": "^1.2.0" + }, + "allowScripts": { + "bcrypt@6.0.0": true, + "better-sqlite3@12.10.0": true, + "esbuild@0.28.0": true, + "esbuild@0.25.12": true } } diff --git a/server/adapters/claude/hook-config.ts b/server/adapters/claude/hook-config.ts index b3edbd3..79674fb 100644 --- a/server/adapters/claude/hook-config.ts +++ b/server/adapters/claude/hook-config.ts @@ -76,15 +76,21 @@ export class ClaudeHookConfig { existing.hooks[event] = [...filtered, ...configs]; } - // Insert our statusLine script into the pipe chain (if not already there). - // Our script is a passthrough: reads stdin, POSTs to server (background), outputs stdin. - // - Has custom statusLine → pipe through our script first - // - No custom statusLine → don't touch it, preserve Claude Code built-in - const wrapperScript = this._ensureStatusLineScript(statuslineUrl); + // Wire up the statusLine so we receive context-window/model/cost data. + // - Has custom statusLine → pipe our passthrough script in front of it (POST + original coexist) + // - No custom statusLine → install our own renderer (POST + render a simple status line), + // since Claude's built-in status line isn't invokable as a standalone command const existingCmd = existing.statusLine?.command || ''; - if (existingCmd && !existingCmd.includes(wrapperScript)) { + const wrapperScript = this._statusLineScriptPath(); + const defaultScript = this._defaultStatusLineScriptPath(); + if (existingCmd && !existingCmd.includes(wrapperScript) && !existingCmd.includes(defaultScript)) { + this._ensureStatusLineScript(statuslineUrl); existing.statusLine = { type: 'command', command: `${wrapperScript} | ${existingCmd}` }; console.log(`[hooks] Wrapped statusLine to POST to ${statuslineUrl}`); + } else if (!existingCmd) { + this._ensureDefaultStatusLineScript(statuslineUrl); + existing.statusLine = { type: 'command', command: defaultScript }; + console.log(`[hooks] Installed default statusLine to POST to ${statuslineUrl}`); } writeFileSync(settingsPath, JSON.stringify(existing, null, 2)); @@ -124,9 +130,13 @@ export class ClaudeHookConfig { if (Object.keys(existing.hooks).length === 0) delete existing.hooks; } - // --- Restore statusLine: remove our script from the pipe chain --- + // --- Restore statusLine --- const wrapperScript = this._statusLineScriptPath(); - if (existing.statusLine?.command?.includes(wrapperScript)) { + const defaultScript = this._defaultStatusLineScriptPath(); + if (existing.statusLine?.command?.includes(defaultScript)) { + // We installed this standalone — there was nothing to restore + delete existing.statusLine; + } else if (existing.statusLine?.command?.includes(wrapperScript)) { // Remove our script + pipe from the command string const restored = existing.statusLine.command .replace(`${wrapperScript} | `, '') @@ -185,6 +195,52 @@ printf '%s' "$input" return scriptPath; } + /** Path to our standalone statusLine renderer (used when the user has no custom statusLine) */ + private _defaultStatusLineScriptPath(): string { + return join(homedir(), '.clawtap', 'hooks', 'claude-statusline-default.sh'); + } + + /** + * Create or update our standalone statusLine renderer. + * POSTs the raw input to ClawTap (background) and prints a simple status line + * (model · cwd · context %) to stdout, since Claude requires a command to render + * its own output when `statusLine.type` is `command`. + */ + private _ensureDefaultStatusLineScript(statuslineUrl: string): string { + const scriptPath = this._defaultStatusLineScriptPath(); + const scriptDir = join(homedir(), '.clawtap', 'hooks'); + mkdirSync(scriptDir, { recursive: true }); + + const portCheck = this._portCheckCmd(); + const curlInsecure = this.useHttps ? ' -k' : ''; + const script = `#!/bin/bash +input=$(cat) +# POST to ClawTap server (non-blocking, skip if server not running) +if ${portCheck}; then + printf '%s' "$input" | curl -sf${curlInsecure} --connect-timeout 2 --max-time 5 -X POST -H 'Content-Type:application/json' -d @- ${statuslineUrl} &>/dev/null & +fi +# Render a simple status line: model · cwd · context % +printf '%s' "$input" | node -e ' +let data = ""; +process.stdin.on("data", c => data += c); +process.stdin.on("end", () => { + try { + const j = JSON.parse(data); + const parts = []; + if (j.model?.display_name) parts.push(j.model.display_name); + const dir = j.workspace?.current_dir || j.cwd; + if (dir) parts.push(dir.replace(process.env.HOME || "", "~")); + const pct = j.context_window?.used_percentage; + if (pct != null) parts.push(\`ctx \${Math.round(pct)}%\`); + process.stdout.write(parts.join(" · ")); + } catch {} +}); +' +`; + writeFileSync(scriptPath, script, { mode: 0o755 }); + return scriptPath; + } + private _isOurHookEntry(entry: HookEntry, portTag: string): boolean { const hooks = entry.hooks || []; return hooks.some(h => diff --git a/server/adapters/claude/index.ts b/server/adapters/claude/index.ts index 54d0fc5..5a303d9 100644 --- a/server/adapters/claude/index.ts +++ b/server/adapters/claude/index.ts @@ -64,7 +64,7 @@ export class ClaudeAdapter extends IAdapter { const events: string[] = [ 'streaming-text', 'thinking', 'tool-start', 'tool-done', 'tool-updates', 'new-messages', 'session-idle', - 'permission-request', 'ask-question', 'mode-changed', + 'permission-request', 'ask-question', 'interactive-prompt', 'mode-changed', 'session-ended', 'session-error', 'compacting', 'compact-done', 'processing-started', ]; @@ -192,7 +192,7 @@ export class ClaudeAdapter extends IAdapter { async attachSession(sid: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { return this._tmux.attachSession(sid, cwd, options); } async destroySession(sid: string): Promise { return this._tmux.destroySession(sid); } async sendMessage(sid: string, text: string, options?: QueryOptions): Promise { return this._tmux.sendMessage(sid, text, options); } - async respondPlan(sid: string, optionIndex: number, text?: string): Promise { return this._tmux.respondPlan(sid, optionIndex, text); } + async respondPlan(sid: string, optionIndex: number, text?: string, requestId?: string): Promise { return this._tmux.respondPlan(sid, optionIndex, text, requestId); } async switchModel(sid: string, model: string): Promise { return this._tmux.switchModel(sid, model); } async interrupt(sid: string): Promise { return this._tmux.interrupt(sid); } flushMessages(sid: string): void { this._tmux.flushMessages(sid); } diff --git a/server/adapters/claude/tmux-adapter.ts b/server/adapters/claude/tmux-adapter.ts index 98d2c1d..6d99e22 100644 --- a/server/adapters/claude/tmux-adapter.ts +++ b/server/adapters/claude/tmux-adapter.ts @@ -200,7 +200,7 @@ export class TmuxAdapter extends EventEmitter { return { sessionId }; } // Window gone — stop old watcher before replacing - this._teardownSession(existingSession); + this._teardownSession(sessionId, existingSession); this.sessions.delete(sessionId); } @@ -220,7 +220,7 @@ export class TmuxAdapter extends EventEmitter { // Register before _waitForReady (same pattern as startSession) this.sessions.set(newSessionId, this._createSession(windowId, cwd, cliUuid, mode)); - await this._waitForReady(windowId); + await this._waitForReady(windowId, 30000, newSessionId); this._startMonitor(newSessionId, windowId); await this._ensureWatcher(newSessionId); @@ -260,7 +260,7 @@ export class TmuxAdapter extends EventEmitter { async destroySession(sessionId: string): Promise { const session = this.sessions.get(sessionId); if (!session) return; - this._teardownSession(session); + this._teardownSession(sessionId, session); await tmuxManager.killWindow(session.windowId); this.sessions.delete(sessionId); this.emit('session-ended', sessionId); @@ -299,7 +299,11 @@ export class TmuxAdapter extends EventEmitter { state.pendingRequests.push({ type: 'permission', requestId: perm.requestId, toolName: perm.toolName, input: perm.input }); } for (const q of this._permissions.getQuestionsForSession(sessionId)) { - state.pendingRequests.push({ type: 'question', requestId: q.requestId, toolName: 'AskUserQuestion', input: q.originalInput }); + if (q.promptType === 'plan') { + state.pendingRequests.push({ type: 'plan', requestId: q.requestId, toolName: 'ExitPlanMode', input: q.originalInput }); + } else { + state.pendingRequests.push({ type: 'question', requestId: q.requestId, toolName: 'AskUserQuestion', input: q.originalInput }); + } } return state; } @@ -432,6 +436,24 @@ export class TmuxAdapter extends EventEmitter { return; } + // ExitPlanMode: the CLI shows its plan-approval selector immediately, + // but the assistant message carrying this tool_use (with the plan text) + // often isn't flushed to the JSONL transcript until *after* the user + // responds — so the JSONL-driven PlanMode card has nothing to render yet. + // Emit the plan text from the hook payload directly via interactive-prompt. + if (body.tool_name === 'ExitPlanMode') { + const requestId = `plan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + this._permissions.addQuestion(requestId, ctx.sessionId, { originalInput: body.tool_input || {}, promptType: 'plan' }); + this.emit('interactive-prompt', ctx.sessionId, { + requestId, + promptType: 'plan', + title: 'Plan ready', + toolName: 'ExitPlanMode', + toolInput: body.tool_input, + }); + return; + } + this.emit('tool-start', ctx.sessionId, { toolId: body.tool_use_id, toolName: body.tool_name, @@ -491,7 +513,7 @@ export class TmuxAdapter extends EventEmitter { const session = this.sessions.get(sessionId); if (session) { - this._teardownSession(session); + this._teardownSession(sessionId, session); this.sessions.delete(sessionId); } @@ -614,41 +636,109 @@ export class TmuxAdapter extends EventEmitter { respondInteractivePrompt(requestId: string, selectedOption?: string, textValue?: string): void { if (textValue != null) { this.respondQuestion(requestId, textValue); - } else if (selectedOption != null) { - // Permission behaviors are named ('allow', 'allow_session', 'deny') - // Question options are numeric indices ('0', '1', '2') - const isPermission = ['allow', 'allow_session', 'deny'].includes(selectedOption); - if (isPermission) { - this.respondPermission(requestId, selectedOption as any); - } else { - // Numeric index — validate before consuming the pending entry - const index = parseInt(selectedOption); - if (isNaN(index)) return; + return; + } + if (selectedOption == null) return; + + // Route by the pending entry's *actual* type — not by guessing from the + // string value. ('deny' is sent both as a real permission behavior and as + // the close-overlay dismiss signal; treating it as a permission behavior + // for a pending *question* would misroute it and either no-op or, worse, + // send something to the CLI the user never chose.) + if (this._permissions.hasQuestion(requestId)) { + const index = parseInt(selectedOption); + if (!isNaN(index)) { const pending = this._permissions.resolveQuestion(requestId); if (!pending) return; const session = this.sessions.get(pending.sessionId); if (!session) return; this._selectOption(session.windowId, index).catch(() => {}); + } else { + // Closing the overlay ('deny' or any non-numeric value): this is the + // user's explicit decision not to answer via mobile — relay it as Esc + // (cancel) on the exact selector we ourselves detected and forwarded. + // We're not guessing at CLI state here; we know precisely what's on + // screen because we're the ones who put it there. + const pending = this._permissions.resolveQuestion(requestId); + if (pending) { + const session = this.sessions.get(pending.sessionId); + if (session) { + tmuxManager.sendControl(session.windowId, 'Escape').catch(() => {}); + } + } } + return; + } + + if (this._permissions.hasPermission(requestId)) { + this.respondPermission(requestId, selectedOption as any); } } /** * Respond to the CLI's plan approval selector. - * Options: 0=bypass (auto-accept edits), 1=manually approve, 2=text feedback + * See PLAN_OPTION in ws-types.ts for the current option layout. */ - async respondPlan(sessionId: string, optionIndex: number, text?: string): Promise { + async respondPlan(sessionId: string, optionIndex: number, text?: string, requestId?: string): Promise { const session = this.sessions.get(sessionId); if (!session || optionIndex < 0 || optionIndex > PLAN_OPTION.TEXT_FEEDBACK) return; - if (optionIndex === PLAN_OPTION.TEXT_FEEDBACK && text) { - await this._selectOption(session.windowId, PLAN_OPTION.TEXT_FEEDBACK); - await new Promise(r => setTimeout(r, 200)); - await tmuxManager.sendKeys(session.windowId, text, true); + if (requestId) this._permissions.resolveQuestion(requestId); + if (optionIndex === PLAN_OPTION.TEXT_FEEDBACK) { + // The feedback/"Tell Claude what to change" item is always last, but its + // index varies (e.g. an extra "refine with Ultraplan" option may or may + // not be present) — count the rendered options instead of assuming 3. + const lastIndex = await this._lastPlanOptionIndex(session.windowId); + if (lastIndex === null) { + // Couldn't verify the selector layout — refuse to guess. An overshoot + // could wrap the selection onto "Yes, bypass permissions" instead of + // the feedback option, so do nothing rather than risk that. + console.error(`[${sessionId}] respondPlan: could not locate plan selector on screen, aborting feedback selection`); + return; + } + await this._selectOption(session.windowId, lastIndex); + if (text) { + await new Promise(r => setTimeout(r, 200)); + await tmuxManager.sendKeys(session.windowId, text, true); + } } else { await this._selectOption(session.windowId, optionIndex); } } + /** + * Count numbered options in the plan-approval selector currently on screen; + * returns the 0-based index of the last one, or null if the selector + * couldn't be located. + * + * Anchors on the `❯`-marked (currently selected) numbered line rather than + * the CLI's prompt wording — the plan's own markdown may contain numbered + * lists, which would otherwise be miscounted as selector options, and this + * approach survives the prompt text wrapping at narrow pane widths. The + * `❯` cursor can only be on a real selector item, so expanding the + * contiguous numbered-line block around it gives the true option count + * regardless of which item is currently highlighted. + */ + private async _lastPlanOptionIndex(windowId: string): Promise { + try { + const content = await tmuxManager.capturePane(windowId); + const lines = content.split('\n'); + const optionLine = /^\s*❯?\s*\d+\./; + const cursorLine = /^\s*❯\s*\d+\./; + let cursorIdx = -1; + for (let i = lines.length - 1; i >= 0; i--) { + if (cursorLine.test(lines[i])) { cursorIdx = i; break; } + } + if (cursorIdx < 0) return null; + let lo = cursorIdx, hi = cursorIdx; + while (lo > 0 && optionLine.test(lines[lo - 1])) lo--; + while (hi < lines.length - 1 && optionLine.test(lines[hi + 1])) hi++; + const count = hi - lo + 1; + return count > 0 ? count - 1 : null; + } catch { + return null; + } + } + /** * Navigate a CLI interactive selector by pressing Down `index` times, then Enter. * Cursor starts on option 0 (first item), so index=0 just presses Enter. @@ -692,7 +782,7 @@ export class TmuxAdapter extends EventEmitter { for (const [sessionId, session] of this.sessions) { if (!liveWindowIds.has(session.windowId)) { console.log(`[tmux] Stale session ${sessionId} — tmux window gone, cleaning up`); - this._teardownSession(session); + this._teardownSession(sessionId, session); this.sessions.delete(sessionId); this.emit('session-ended', sessionId); } @@ -703,7 +793,7 @@ export class TmuxAdapter extends EventEmitter { .sort((a, b) => (b[1].lastActivity || 0) - (a[1].lastActivity || 0)); for (const [id] of sorted.slice(10)) { const s = this.sessions.get(id); - if (s) this._teardownSession(s); + if (s) this._teardownSession(id, s); this.sessions.delete(id); this.emit('session-ended', id); } @@ -736,9 +826,12 @@ export class TmuxAdapter extends EventEmitter { }; } - private _teardownSession(session: SessionState): void { + private _teardownSession(sessionId: string, session: SessionState): void { if (session.monitor) { session.monitor.stop(); session.monitor = null; } if (session.watcher) { session.watcher.stop(); session.watcher = null; session.parser = null; } + // Drop any pending permission/question/plan prompts — the CLI window + // they referred to is gone, so they can never be answered. + this._permissions.dismissAll(sessionId); } async destroy(): Promise { @@ -746,8 +839,8 @@ export class TmuxAdapter extends EventEmitter { clearInterval(this._cleanupInterval); this._cleanupInterval = null; } - for (const [, session] of this.sessions) { - this._teardownSession(session); + for (const [sessionId, session] of this.sessions) { + this._teardownSession(sessionId, session); } this.sessions.clear(); await tmuxManager.killSession(); @@ -885,9 +978,18 @@ export class TmuxAdapter extends EventEmitter { return windows.some(w => w.id === windowId); } - private async _waitForReady(windowId: string, timeoutMs: number = 30000): Promise { - const start = Date.now(); + private async _waitForReady(windowId: string, timeoutMs: number = 30000, sessionId?: string): Promise { + // PermissionManager silently drops pending questions after its internal + // timeout (120s, see `new PermissionManager()` above). Refresh ours well + // before that so a slow mobile response is never a no-op — we never pick + // an option on the user's behalf. + const RESUME_PROMPT_REFRESH_MS = 90_000; + let start = Date.now(); let attempt = 0; + let resumePromptRequestId: string | null = null; + let resumePromptRefreshedAt: number | null = null; + let genericPromptRequestId: string | null = null; + let genericPromptRefreshedAt: number | null = null; while (Date.now() - start < timeoutMs) { attempt++; try { @@ -915,6 +1017,87 @@ export class TmuxAdapter extends EventEmitter { continue; } + // Long-session resume prompt ("Resume from summary / Resume full session as-is / Don't ask me again"). + // Forward it to the client so the user decides — don't auto-select. + const isResumePrompt = isSelectionPrompt + && /Resume from summary/i.test(content) + && /Resume full session as-is/i.test(content); + if (isResumePrompt && sessionId) { + const options = [ + { value: '0', label: 'Resume from summary (recommended)' }, + { value: '1', label: 'Resume full session as-is' }, + { value: '2', label: "Don't ask me again" }, + ]; + if (!resumePromptRequestId) { + resumePromptRequestId = `claude-resume-${sessionId}`; + resumePromptRefreshedAt = Date.now(); + const tail = lines.slice(-20); + const description = tail.join('\n').trim(); + this._permissions.addQuestion(resumePromptRequestId, sessionId, { originalInput: { questions: [{ options }] } }); + this.emit('interactive-prompt', sessionId, { + requestId: resumePromptRequestId, + promptType: 'question', + title: 'Resume long session', + description, + options, + }); + console.log(`[adapter] Resume-summary prompt detected for ${sessionId}, awaiting user choice`); + } else if (Date.now() - resumePromptRefreshedAt! > RESUME_PROMPT_REFRESH_MS) { + // Re-arm the pending entry so PermissionManager's internal timeout + // never expires it while we're still waiting on the user. + this._permissions.resolveQuestion(resumePromptRequestId); + this._permissions.addQuestion(resumePromptRequestId, sessionId, { originalInput: { questions: [{ options }] } }); + resumePromptRefreshedAt = Date.now(); + } + // Keep the outer wait-loop alive while the prompt is on screen — + // the user may take a while to respond from their phone. + start = Date.now(); + await new Promise(r => setTimeout(r, 1000)); + continue; + } + + // Generic fallback: any other numbered selection prompt we don't + // specifically recognize (e.g. a resumed session paused mid- + // AskUserQuestion). Its option lines also start with ❯, so without + // this check it would be misread as "ready" — and sendMessage would + // then type blindly into a live selector, where Enter could submit + // whatever option happens to be highlighted (auto-answering for the + // user). Forward it to mobile and keep waiting instead. + if (isSelectionPrompt && sessionId) { + const optionMatches = [...content.matchAll(/❯?\s*\d+\.\s+(.+?)\s*$/gm)]; + // _selectOption navigates Down N times from whatever's currently + // highlighted (0-based, relative) — NOT the displayed label number. + // Using the label number as the value would be off-by-one and could + // select the wrong option entirely. + const options = optionMatches + .map((m, i) => ({ value: String(i), label: m[1].trim() })) + .filter(o => o.label.length > 0); + if (options.length > 0) { + if (!genericPromptRequestId) { + genericPromptRequestId = `claude-selector-${sessionId}`; + genericPromptRefreshedAt = Date.now(); + const tail = lines.slice(-20); + const description = tail.join('\n').trim(); + this._permissions.addQuestion(genericPromptRequestId, sessionId, { originalInput: { questions: [{ options }] } }); + this.emit('interactive-prompt', sessionId, { + requestId: genericPromptRequestId, + promptType: 'question', + title: 'Pending prompt', + description, + options, + }); + console.log(`[adapter] Unrecognized selection prompt detected for ${sessionId}, forwarding to client`); + } else if (Date.now() - genericPromptRefreshedAt! > RESUME_PROMPT_REFRESH_MS) { + this._permissions.resolveQuestion(genericPromptRequestId); + this._permissions.addQuestion(genericPromptRequestId, sessionId, { originalInput: { questions: [{ options }] } }); + genericPromptRefreshedAt = Date.now(); + } + start = Date.now(); + await new Promise(r => setTimeout(r, 1000)); + continue; + } + } + if (hasPrompt && lineCount >= 3) { console.log(`[adapter] CLI ready for ${windowId} in ${Date.now() - start}ms`); await new Promise(r => setTimeout(r, 300)); diff --git a/server/adapters/codex/codex-tmux-adapter.ts b/server/adapters/codex/codex-tmux-adapter.ts index d170712..351d6e0 100644 --- a/server/adapters/codex/codex-tmux-adapter.ts +++ b/server/adapters/codex/codex-tmux-adapter.ts @@ -616,8 +616,13 @@ export class CodexTmuxAdapter extends EventEmitter { if (!session) return; if (selectedOption != null) { - // Codex uses single-key shortcuts (y, a, p, d, n) - tmuxManager.sendKeys(session.windowId, selectedOption, false).catch(() => {}); + // Codex uses single-key shortcuts (y, a, p, d, n) or numbered options. + // The overlay also sends 'deny' as a generic close/dismiss signal — + // that is NOT a Codex keystroke and must never be typed into the CLI + // (it would literally type the word "deny" into the terminal). + if (/^[yandp]$/i.test(selectedOption) || /^\d+$/.test(selectedOption)) { + tmuxManager.sendKeys(session.windowId, selectedOption, false).catch(() => {}); + } } if (textValue != null) { tmuxManager.sendKeys(session.windowId, textValue, true).catch(() => {}); diff --git a/server/adapters/interface.ts b/server/adapters/interface.ts index 0a81bc6..11c44a5 100644 --- a/server/adapters/interface.ts +++ b/server/adapters/interface.ts @@ -100,7 +100,7 @@ export class IAdapter extends EventEmitter { // --- Messaging --- async sendMessage(sessionId: string, text: string, options?: QueryOptions): Promise { throw new Error('Not implemented: sendMessage'); } - async respondPlan(sessionId: string, optionIndex: number, text?: string): Promise {} + async respondPlan(sessionId: string, optionIndex: number, text?: string, requestId?: string): Promise {} async interrupt(sessionId: string): Promise { throw new Error('Not implemented: interrupt'); } async switchModel(sessionId: string, model: string): Promise {} flushMessages(sessionId: string): void {} diff --git a/server/auth.ts b/server/auth.ts index 5374da6..9f20ea0 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -59,6 +59,10 @@ export async function login( return { token }; } +export function resetLoginAttempts(ip?: string): void { + rateLimit.reset(ip); +} + export function verifyToken(token: string): Record | null { try { return jwt.verify(token, jwtSecret) as Record; diff --git a/server/db.ts b/server/db.ts index e85cd0b..ba45418 100644 --- a/server/db.ts +++ b/server/db.ts @@ -178,6 +178,12 @@ function stmts(): PreparedStatements { rateLimitCleanup: d.prepare( `DELETE FROM login_attempts WHERE attempted_at < datetime('now', '-1 hour')` ), + rateLimitReset: d.prepare( + `DELETE FROM login_attempts WHERE ip = ?` + ), + rateLimitResetAll: d.prepare( + `DELETE FROM login_attempts` + ), // preferences preferencesGet: d.prepare( `SELECT value FROM user_preferences WHERE key = ?` @@ -297,6 +303,14 @@ export const rateLimit = { cleanup(): void { stmts().rateLimitCleanup.run(); }, + + reset(ip?: string): void { + if (ip) { + stmts().rateLimitReset.run(ip); + } else { + stmts().rateLimitResetAll.run(); + } + }, }; // --- User Preferences Operations --- diff --git a/server/index.ts b/server/index.ts index 319171f..74420bc 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,6 +10,7 @@ import { initAuth, login, authMiddleware, + resetLoginAttempts, } from './auth.js'; import './adapters/init.js'; import { initAll, installAllHooks, listAvailable, get as getAdapter, getAll as getAllAdapters, cleanupAll, DEFAULT_ADAPTER } from './adapters/registry.js'; @@ -31,7 +32,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); import multer from 'multer'; import { randomUUID } from 'crypto'; -import { mkdirSync, writeFileSync, unlinkSync } from 'fs'; +import { mkdirSync, writeFileSync, unlinkSync, existsSync } from 'fs'; import type { Server as HttpServer } from 'http'; import type { Server as HttpsServer } from 'https'; @@ -73,6 +74,18 @@ async function start(): Promise { res.json({ status: 'ok', branch: config.gitBranch, version: pkg.version }); }); + // Serve the self-signed cert so phones can download + trust it directly + // (no auth — the device needs this before it can trust the HTTPS connection). + app.get('/cert', (_req: Request, res: Response) => { + const certPath = join(config.clawtapDir, 'cert.pem'); + if (!config.https || !existsSync(certPath)) { + return res.status(404).json({ error: 'No certificate configured. Run `clawtap cert` to generate one.' }); + } + res.setHeader('Content-Type', 'application/x-x509-ca-cert'); + res.setHeader('Content-Disposition', 'attachment; filename="clawtap-ca.pem"'); + res.sendFile(certPath, { dotfiles: 'allow' }); + }); + app.post('/api/auth/login', async (req: Request, res: Response) => { const { password } = req.body as { password?: string }; if (!password) { @@ -86,6 +99,14 @@ async function start(): Promise { res.json({ token: (result as { token: string }).token }); }); + // Manual reset of login rate limiting. Restricted to localhost (via authMiddleware's + // bypass) since a locked-out user has no token but can run `clawtap reset-login` locally. + app.post('/api/auth/reset-attempts', authMiddleware, (req: Request, res: Response) => { + const { ip } = req.body as { ip?: string }; + resetLoginAttempts(ip); + res.json({ ok: true }); + }); + app.get('/api/sessions', authMiddleware, async (req: Request, res: Response) => { try { const { dir, limit } = req.query as { dir?: string; limit?: string }; diff --git a/server/permission-manager.ts b/server/permission-manager.ts index 5bbd9da..f31e9b3 100644 --- a/server/permission-manager.ts +++ b/server/permission-manager.ts @@ -17,7 +17,10 @@ export interface PendingQuestion { sessionId: string; requestId: string; originalInput: Record; - timer: ReturnType; + /** Distinguishes AskUserQuestion prompts from ExitPlanMode plan-approval prompts. */ + promptType: 'question' | 'plan'; + // Questions never auto-expire (see addQuestion) — no timer to track. + timer: null; } export class PermissionManager { @@ -49,6 +52,10 @@ export class PermissionManager { this._trackPendingId(sessionId, requestId); } + hasPermission(requestId: string): boolean { + return this.pendingPermissions.has(requestId); + } + resolvePermission(requestId: string): PendingPermission | undefined { const pending = this.pendingPermissions.get(requestId); if (!pending) return undefined; @@ -60,25 +67,31 @@ export class PermissionManager { // === Questions === - addQuestion(requestId: string, sessionId: string, data: { originalInput: Record }): void { - const timer = setTimeout(() => { - this.pendingQuestions.delete(requestId); - this._removePendingId(sessionId, requestId); - }, this.timeoutMs); - + addQuestion(requestId: string, sessionId: string, data: { originalInput: Record; promptType?: 'question' | 'plan' }): void { + // Unlike permissions (which pair with a client-side auto-deny countdown + // that reaches the same end state), nothing ever answers a question on + // a timeout — the CLI just keeps waiting indefinitely. Silently dropping + // our tracking after timeoutMs would make ClawTap forget a prompt that's + // still live on screen, producing a "ghost" the UI can never show again. + // Questions are cleaned up via resolveQuestion/dismissAll (session end), + // never by elapsed time. this.pendingQuestions.set(requestId, { sessionId, requestId, originalInput: data.originalInput, - timer, + promptType: data.promptType || 'question', + timer: null, }); this._trackPendingId(sessionId, requestId); } + hasQuestion(requestId: string): boolean { + return this.pendingQuestions.has(requestId); + } + resolveQuestion(requestId: string): PendingQuestion | undefined { const pending = this.pendingQuestions.get(requestId); if (!pending) return undefined; - clearTimeout(pending.timer); this.pendingQuestions.delete(requestId); this._removePendingId(pending.sessionId, requestId); return pending; @@ -118,7 +131,7 @@ export class PermissionManager { const perm = this.pendingPermissions.get(reqId); if (perm) { clearTimeout(perm.timer); this.pendingPermissions.delete(reqId); } const q = this.pendingQuestions.get(reqId); - if (q) { clearTimeout(q.timer); this.pendingQuestions.delete(reqId); } + if (q) { this.pendingQuestions.delete(reqId); } } this.sessionPendingIds.delete(sessionId); } @@ -139,7 +152,7 @@ export class PermissionManager { resolved.push(reqId); } const q = this.pendingQuestions.get(reqId); - if (q) { clearTimeout(q.timer); this.pendingQuestions.delete(reqId); } + if (q) { this.pendingQuestions.delete(reqId); } } this.sessionPendingIds.delete(sessionId); return resolved; diff --git a/server/session-manager.ts b/server/session-manager.ts index 9a40df5..94226ce 100644 --- a/server/session-manager.ts +++ b/server/session-manager.ts @@ -394,7 +394,7 @@ export async function handleIncomingMessage(conn: ClientConnection, msg: ClientM case WS.SET_MODEL: return handleSetModel(conn, msg.sessionId as string, msg.model as string); case WS.PLAN_RESPONSE: - return handlePlanResponse(conn, msg.sessionId as string, msg.optionIndex as number, msg.text as string | undefined); + return handlePlanResponse(conn, msg.sessionId as string, msg.optionIndex as number, msg.text as string | undefined, msg.requestId as string | undefined); case WS.PROMPT_RESPONSE: return handlePromptResponse(conn, msg.requestId as string, { selectedOption: msg.selectedOption as string | undefined, @@ -488,12 +488,13 @@ export async function handleAbort(conn: ClientConnection, sessionId?: string): P if (sid && adapter) await adapter.interrupt(sid); } -export async function handlePlanResponse(conn: ClientConnection, sessionId: string, optionIndex: number, text?: string): Promise { +export async function handlePlanResponse(conn: ClientConnection, sessionId: string, optionIndex: number, text?: string, requestId?: string): Promise { const { adapter, sid } = await getAdapterForSession(conn, sessionId); if (!sid || !adapter) return; - await adapter.respondPlan(sid, optionIndex, text); + await adapter.respondPlan(sid, optionIndex, text, requestId); + if (requestId) broadcast(sid, { type: WS.PROMPT_DISMISSED, requestId }); // Broadcast synthetic user message so plan card transitions to read-only on ALL clients - // Options: 0=bypass (YOLO), 1=manually approve, 2=text feedback + // Options: 0=bypass (YOLO), 1=manually approve, 3=text feedback const labels = ['Plan approved (YOLO).', 'Plan approved.']; const msg = optionIndex === PLAN_OPTION.TEXT_FEEDBACK ? (text || 'Rejected.') : (labels[optionIndex] || 'Plan approved.'); broadcast(sid, { type: WS.MESSAGE_COMPLETE, messages: [{ role: 'user', content: msg }] }); @@ -572,6 +573,17 @@ export async function handleReconnect(conn: ClientConnection, sessionId?: string send(conn, { type: WS.TOOL_UPDATES, tools: pending.tools }); } for (const req of pending.pendingRequests) { + if (req.type === 'plan') { + send(conn, { + type: WS.INTERACTIVE_PROMPT, + requestId: req.requestId, + promptType: 'plan', + title: 'Plan ready', + toolName: req.toolName, + toolInput: req.input, + }); + continue; + } const { type: _type, ...rest } = req as Record; send(conn, { type: WS.PERMISSION_REQUEST, ...rest }); } @@ -667,13 +679,14 @@ function registerClient(conn: ClientConnection, sessionId: string): void { if (set) { set.delete(c); if (set.size === 0) { - const adapterName = sessionAdapterMap.get(sid) || DEFAULT_ADAPTER; - const adapter = getAdapter(adapterName); - // Only release pending permissions if session is idle — if processing, - // the client may be refreshing and will reconnect shortly to see the overlay - if (adapter && !adapter.isProcessing(sid)) { - adapter.releaseAllPending(sid); - } + // Do NOT release pending permissions/questions here. They only exist + // because the CLI itself posted a prompt and is genuinely waiting on + // an answer — that's real, live CLI state, not stale UI residue. + // Wiping our tracking just makes ClawTap forget about a prompt that's + // still sitting on screen, so a reconnecting client sees no dialog at + // all (a "ghost" prompt the CLI is waiting on but nothing can answer). + // Let pending entries persist until answered, resolved by the CLI, or + // naturally expired by PermissionManager's internal timeout. console.log(`[session-mgr] All clients disconnected from ${sid}, session persists`); } } diff --git a/server/types/adapter.ts b/server/types/adapter.ts index 983d69e..0ba2623 100644 --- a/server/types/adapter.ts +++ b/server/types/adapter.ts @@ -47,7 +47,7 @@ export interface AdapterInfo { export interface ReconnectState { tools: Record; pendingRequests: Array<{ - type: 'permission' | 'question'; + type: 'permission' | 'question' | 'plan'; requestId: string; toolName?: string; input?: Record; diff --git a/server/ws-types.ts b/server/ws-types.ts index e725342..a80d4f5 100644 --- a/server/ws-types.ts +++ b/server/ws-types.ts @@ -49,13 +49,16 @@ export type WsType = typeof WS[keyof typeof WS]; /** * CLI plan approval selector option indices (ExitPlanMode). - * Claude Code v2.1.x shows 3 options: - * 0: "Yes, auto-accept edits" → BYPASS - * 1: "Yes, manually approve edits" → MANUALLY_APPROVE - * 2: "Type here to tell Claude what to change" → TEXT_FEEDBACK + * Claude Code v2.1.177 shows 4 options: + * 0: "Yes, and bypass permissions" → BYPASS + * 1: "Yes, manually approve edits" → MANUALLY_APPROVE + * 2: "No, refine with Ultraplan on Claude Code on the web" + * 3: "Tell Claude what to change" → TEXT_FEEDBACK + * _selectOption clamps to the last option when overshooting, so + * TEXT_FEEDBACK also lands correctly on older 3-option layouts. */ export const PLAN_OPTION = { BYPASS: 0, MANUALLY_APPROVE: 1, - TEXT_FEEDBACK: 2, + TEXT_FEEDBACK: 3, } as const; diff --git a/src/App.tsx b/src/App.tsx index d2c8257..33b3422 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { STORAGE } from './lib/storage-keys'; -import { isAuthenticated, clearToken } from './lib/api'; +import { isAuthenticated, clearToken, AUTH_EXPIRED_EVENT } from './lib/api'; import { usePushNotifications } from './hooks/usePushNotifications'; import { LoginView } from './components/LoginView'; import { SessionsView } from './components/SessionsView'; @@ -89,6 +89,13 @@ export function App() { setAuthed(false); }, []); + // Server rejected our token (expired/invalid) — drop back to login + useEffect(() => { + const handler = () => handleLogout(); + window.addEventListener(AUTH_EXPIRED_EVENT, handler); + return () => window.removeEventListener(AUTH_EXPIRED_EVENT, handler); + }, [handleLogout]); + const openChat = useCallback((sessionId?: string, cwd?: string, adapter?: string) => { if (!sessionId && cwd) { const v: View = { name: 'newchat', cwd }; @@ -130,6 +137,28 @@ export function App() { return () => navigator.serviceWorker.removeEventListener('controllerchange', handleControllerChange); }, []); + // PWA: actively poll for service worker updates. + // iOS standalone PWAs don't reliably perform the browser's normal + // "check sw.js for byte changes on navigation" — without this, an + // installed app can stay on a stale bundle indefinitely. Forcing + // registration.update() on focus/interval triggers that check. + useEffect(() => { + if (!navigator.serviceWorker) return; + const checkForUpdate = () => { + navigator.serviceWorker.getRegistration().then(reg => reg?.update()).catch(() => {}); + }; + const handleVisibility = () => { + if (document.visibilityState === 'visible') checkForUpdate(); + }; + checkForUpdate(); + document.addEventListener('visibilitychange', handleVisibility); + const interval = setInterval(checkForUpdate, 60 * 60 * 1000); + return () => { + document.removeEventListener('visibilitychange', handleVisibility); + clearInterval(interval); + }; + }, []); + // PWA iOS: Sync --app-height CSS variable to the true viewport height. // WebKit bug: after a keyboard open/close cycle, viewport-fit=cover's layout // extension is lost, causing 100dvh to shrink permanently. We track the max diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 0920dea..692c913 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -12,6 +12,7 @@ import { CollapsedReviewCard } from './CollapsedReviewCard'; import { BlockMarker } from './BlockMarker'; import { TaskFab } from './TaskFab'; import { TaskBottomSheet } from './TaskBottomSheet'; +import { BottomSheet } from './BottomSheet'; import { api } from '../lib/api'; import { getBrand } from '../lib/adapter-brands'; import { extractTextFromBlocks } from '../lib/content-utils'; @@ -606,12 +607,22 @@ export function ChatView({ setTaskSheetOpen(false)} /> {/* Interactive prompt overlay (permissions, questions, plan approval, etc.) */} - {interactivePrompt && ( + {interactivePrompt && interactivePrompt.promptType === 'plan' ? ( + + respondPlan(PLAN_OPTION.MANUALLY_APPROVE, undefined, interactivePrompt.requestId)} + onApproveYolo={() => respondPlan(PLAN_OPTION.BYPASS, undefined, interactivePrompt.requestId)} + onReject={(feedback: string) => respondPlan(PLAN_OPTION.TEXT_FEEDBACK, feedback || 'Rejected', interactivePrompt.requestId)} + onSendFeedback={(feedback: string) => respondPlan(PLAN_OPTION.TEXT_FEEDBACK, feedback, interactivePrompt.requestId)} + /> + + ) : interactivePrompt ? ( - )} + ) : null} ); } diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index bcba107..23716ba 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { ChevronLeft, ChevronRight, ClipboardList, Bell, Info } from 'lucide-react'; +import { ChevronLeft, ChevronRight, ClipboardList, Bell, Info, ShieldCheck } from 'lucide-react'; import { api } from '@/lib/api'; import { AdapterIcon } from './AdapterIcon'; import { SavedInstructionsView } from './SavedInstructionsView'; @@ -16,6 +16,7 @@ export function SettingsView({ onBack }: { onBack: () => void }) { const [subView, setSubView] = useState<'main' | 'instructions' | string>('main'); const [adapters, setAdapters] = useState([]); const [version, setVersion] = useState(''); + const [certAvailable, setCertAvailable] = useState(false); const { supported, subscribed, subscribe, unsubscribe } = usePushNotifications(); const [toggling, setToggling] = useState(false); @@ -25,6 +26,9 @@ export function SettingsView({ onBack }: { onBack: () => void }) { .then(r => r.json()) .then((data: { version: string }) => setVersion(data.version)) .catch(() => {}); + fetch('/cert', { method: 'HEAD' }) + .then(r => setCertAvailable(r.ok)) + .catch(() => {}); }, []); if (subView === 'instructions') { @@ -87,6 +91,29 @@ export function SettingsView({ onBack }: { onBack: () => void }) { ))} + {/* Install HTTPS certificate (only relevant when serving over a self-signed cert) */} + {window.location.protocol === 'https:' && ( + certAvailable ? ( + + + Install HTTPS Certificate + + + ) : ( +
+ +
+
Install HTTPS Certificate
+
No cert found — run `clawtap cert` on the server
+
+
+ ) + )} + {/* Notifications */} {supported && (
diff --git a/src/hooks/useChat.ts b/src/hooks/useChat.ts index 16d052e..8fdabb0 100644 --- a/src/hooks/useChat.ts +++ b/src/hooks/useChat.ts @@ -492,7 +492,11 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter? useEffect(() => { const token = localStorage.getItem(STORAGE.TOKEN); if (!token) return; - const client = new WsClient(token, handleWsMessage, setWsStatus); + const client = new WsClient(token, handleWsMessage, setWsStatus, () => { + // Connection handshake failed — verify the token is still valid (a 401/403 + // here triggers the global auth-expired logout via api.ts). + api.adapters().catch(() => {}); + }); wsRef.current = client; if (initialSessionId) { client.setActiveSession(initialSessionId, selectedAdapter ?? undefined); @@ -601,17 +605,19 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter? }, []); // --- Plan Response --- - const respondPlan = useCallback((optionIndex: number, text?: string) => { + const respondPlan = useCallback((optionIndex: number, text?: string, requestId?: string) => { // Enter streaming mode — CLI will start executing tools after approval setStreaming(true); setPendingResponse(true); streamingRef.current = true; setToolStatuses(new Map()); + if (requestId) setInteractivePrompt(null); wsRef.current?.send({ type: WS.PLAN_RESPONSE, sessionId, optionIndex, text, + requestId, }); }, [sessionId]); diff --git a/src/lib/api.ts b/src/lib/api.ts index e1dfb14..519808d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -14,6 +14,14 @@ export function clearToken() { localStorage.removeItem(STORAGE.TOKEN); } +/** Fired when the server rejects the stored token (expired/invalid). */ +export const AUTH_EXPIRED_EVENT = 'clawtap:auth-expired'; + +function handleAuthExpired() { + clearToken(); + window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT)); +} + export function isAuthenticated(): boolean { return !!getToken(); } @@ -36,6 +44,9 @@ async function request(path: string, options: RequestInit = {}): Promise { } if (!res.ok) { + if ((res.status === 401 || res.status === 403) && token) { + handleAuthExpired(); + } const body = await res.json().catch(() => ({})); throw new Error(body.error || `HTTP ${res.status}`); } @@ -72,7 +83,12 @@ export const api = { headers: token ? { Authorization: `Bearer ${token}` } : {}, body: form, }); - if (!res.ok) throw new Error('Upload failed'); + if (!res.ok) { + if ((res.status === 401 || res.status === 403) && token) { + handleAuthExpired(); + } + throw new Error('Upload failed'); + } return res.json(); }, diff --git a/src/lib/ws-types.ts b/src/lib/ws-types.ts index 229efdc..518b0da 100644 --- a/src/lib/ws-types.ts +++ b/src/lib/ws-types.ts @@ -47,13 +47,16 @@ export const WS = { /** * CLI plan approval selector option indices (ExitPlanMode). - * Claude Code v2.1.x shows 3 options: - * 0: "Yes, auto-accept edits" → BYPASS - * 1: "Yes, manually approve edits" → MANUALLY_APPROVE - * 2: "Type here to tell Claude what to change" → TEXT_FEEDBACK + * Claude Code v2.1.177 shows 4 options: + * 0: "Yes, and bypass permissions" → BYPASS + * 1: "Yes, manually approve edits" → MANUALLY_APPROVE + * 2: "No, refine with Ultraplan on Claude Code on the web" + * 3: "Tell Claude what to change" → TEXT_FEEDBACK + * _selectOption clamps to the last option when overshooting, so + * TEXT_FEEDBACK also lands correctly on older 3-option layouts. */ export const PLAN_OPTION = { BYPASS: 0, MANUALLY_APPROVE: 1, - TEXT_FEEDBACK: 2, + TEXT_FEEDBACK: 3, } as const; diff --git a/src/lib/ws.ts b/src/lib/ws.ts index b7de168..b8d6b1f 100644 --- a/src/lib/ws.ts +++ b/src/lib/ws.ts @@ -20,21 +20,26 @@ export class WsClient { private activeAdapter: string | null = null; private visibilityHandler: (() => void) | null = null; private hiddenSince: number | null = null; + private openedThisAttempt = false; + private onAuthCheckNeeded: (() => void) | null = null; - constructor(token: string, onMessage: MessageHandler, onStatus: StatusHandler) { + constructor(token: string, onMessage: MessageHandler, onStatus: StatusHandler, onAuthCheckNeeded?: () => void) { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; this.url = `${proto}://${location.host}/ws?token=${encodeURIComponent(token)}`; this.onMessage = onMessage; this.onStatus = onStatus; + this.onAuthCheckNeeded = onAuthCheckNeeded || null; } connect() { this.shouldReconnect = true; + this.openedThisAttempt = false; this.onStatus('connecting'); this.ws = new WebSocket(this.url); this.ws.onopen = () => { + this.openedThisAttempt = true; this.reconnectDelay = 1000; this.onStatus('connected'); @@ -68,6 +73,11 @@ export class WsClient { this.ws.onclose = () => { this.onStatus('disconnected'); + // The handshake itself failed (e.g. server rejected the auth token) — + // check whether our token is still valid rather than reconnecting forever. + if (!this.openedThisAttempt) { + this.onAuthCheckNeeded?.(); + } if (this.shouldReconnect) { this.onStatus('reconnecting'); setTimeout(() => this.connect(), this.reconnectDelay); diff --git a/update-service.sh b/update-service.sh index 3157909..80bd9ff 100755 --- a/update-service.sh +++ b/update-service.sh @@ -1,21 +1,37 @@ #!/usr/bin/env bash set -euo pipefail +log() { echo "[$(date '+%H:%M:%S')] $*"; } +die() { echo "[$(date '+%H:%M:%S')] ERROR: $*" >&2; exit 1; } + +SKIP_FIREWALL=0 +for arg in "$@"; do + case "$arg" in + --skip-firewall) SKIP_FIREWALL=1 ;; + *) die "Unknown argument: $arg" ;; + esac +done + REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SERVICE_NAME="clawtap.service" UNIT_DIR="$HOME/.config/systemd/user" ENV_FILE="$HOME/.clawtap/env" UNIT_FILE="$UNIT_DIR/$SERVICE_NAME" +log "Repo: $REPO_DIR" +log "Service unit: $UNIT_FILE" + # --- Install systemd unit if missing --- if [ ! -f "$UNIT_FILE" ]; then - echo "Installing $SERVICE_NAME..." + log "Installing $SERVICE_NAME..." if [ ! -f "$ENV_FILE" ]; then - echo "ERROR: $ENV_FILE not found." - echo "Create it with at minimum:" - echo " CLAWTAP_PASSWORD=your-password-here" - exit 1 + log "$ENV_FILE not found — creating it now." + mkdir -p "$HOME/.clawtap" + read -rsp "Enter CLAWTAP_PASSWORD: " _pw + echo + echo "CLAWTAP_PASSWORD=$_pw" > "$ENV_FILE" + log "Created $ENV_FILE" fi mkdir -p "$UNIT_DIR" @@ -43,21 +59,132 @@ EOF systemctl --user daemon-reload systemctl --user enable "$SERVICE_NAME" - echo "Service installed and enabled." + log "Service installed and enabled." +else + log "Service unit already exists, skipping install." +fi + +# --- Check dependencies --- +log "Checking for tmux..." +if command -v tmux &>/dev/null; then + log "tmux found: $(tmux -V)" +else + log "tmux not found — installing..." + if command -v pacman &>/dev/null; then + sudo pacman -S --noconfirm tmux + elif command -v apt-get &>/dev/null; then + sudo apt-get install -y tmux + elif command -v brew &>/dev/null; then + brew install tmux + else + die "Cannot install tmux: no supported package manager found (pacman/apt/brew). Install it manually and re-run." + fi + log "tmux installed: $(tmux -V)" +fi + +# --- Open firewall port --- +if [ "$SKIP_FIREWALL" = "1" ]; then + log "Skipping firewall rule (--skip-firewall)." +elif command -v ufw &>/dev/null; then + _port="$(grep -E '^PORT=' "$ENV_FILE" 2>/dev/null | cut -d= -f2 || true)" + _port="${_port:-3456}" + log "Opening port $_port for local network..." + sudo ufw allow from 192.168.0.0/16 to any port "$_port" comment 'ClawTap' +else + log "ufw not found, skipping firewall rule." +fi + +# --- Install dependencies (skip if package files haven't changed) --- +cd "$REPO_DIR" +_pkg_hash="$(cat package.json package-lock.json 2>/dev/null | sha256sum | awk '{print $1}')" +_hash_file="$REPO_DIR/node_modules/.clawtap-install-hash" +if [ -d node_modules ] && [ -f "$_hash_file" ] && [ "$(cat "$_hash_file")" = "$_pkg_hash" ]; then + log "package.json/package-lock.json unchanged, skipping npm install." +else + log "Running npm install..." + npm install + + # Interactively approve/deny any packages with pending install scripts + log "Checking for pending install scripts..." + _pending=$(npm approve-scripts --allow-scripts-pending 2>&1 | grep -E '^\s+\S+@' | awk '{print $1}' || true) + if [ -n "$_pending" ]; then + log "The following packages have pending install scripts:" + echo "$_pending" + echo + for _pkg in $_pending; do + read -rp " Approve install scripts for $_pkg? [y/N] " _ans + if [[ "$_ans" =~ ^[Yy]$ ]]; then + npm approve-scripts "$_pkg" + log "Approved $_pkg" + else + npm deny-scripts "$_pkg" + log "Denied $_pkg" + fi + done + log "Re-running npm install after approvals..." + npm install + else + log "No pending install scripts." + fi + + echo "$_pkg_hash" > "$_hash_file" fi # --- Build frontend --- -echo "Building frontend..." -cd "$REPO_DIR" +log "Building frontend..." npm run build +log "Frontend built successfully." # --- Install CLI binary --- -echo "Installing CLI binary..." -sudo cp "$REPO_DIR/bin/clawtap" /usr/bin/clawtap +log "Installing CLI binary to ~/.local/bin/clawtap..." +mkdir -p "$HOME/.local/bin" +cp "$REPO_DIR/bin/clawtap" "$HOME/.local/bin/clawtap" +chmod +x "$HOME/.local/bin/clawtap" +log "Binary installed." + +# Ensure ~/.local/bin is in PATH for future shells +_fish_config="$HOME/.config/fish/config.fish" +if [ -f "$_fish_config" ] && ! grep -q '\.local/bin' "$_fish_config"; then + echo 'fish_add_path $HOME/.local/bin' >> "$_fish_config" + log "Added ~/.local/bin to PATH in $_fish_config" +elif ! grep -q '\.local/bin' "$HOME/.bashrc" 2>/dev/null && ! grep -q '\.local/bin' "$HOME/.profile" 2>/dev/null; then + echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$HOME/.profile" + log "Added ~/.local/bin to PATH in ~/.profile" +else + log "~/.local/bin already in PATH config, skipping." +fi + +# --- HTTPS certificate --- +_cert="$HOME/.clawtap/cert.pem" +_key="$HOME/.clawtap/key.pem" +if [ -f "$_cert" ] && [ -f "$_key" ]; then + log "HTTPS certificate already exists, skipping generation." +else + read -rp "[$(date '+%H:%M:%S')] Generate self-signed HTTPS certificate? (recommended for PWA/push) [Y/n] " _ans + if [[ ! "$_ans" =~ ^[Nn]$ ]]; then + _lan_ip="$(hostname -I 2>/dev/null | awk '{print $1}' || echo '0.0.0.0')" + log "Generating certificate for IP $_lan_ip..." + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$_key" \ + -out "$_cert" \ + -days 365 \ + -subj "/CN=ClawTap" \ + -addext "subjectAltName=IP:$_lan_ip" \ + 2>/dev/null + chmod 600 "$_key" + log "Certificate generated at ~/.clawtap/{cert,key}.pem" + echo "" + echo " To trust on Android: send cert.pem to your phone → Settings → Security → Install certificate → CA certificate" + echo " To trust on iOS: send cert.pem to your phone → open it → Install Profile → Settings → General → About → Certificate Trust Settings → enable ClawTap" + echo "" + else + log "Skipping certificate generation." + fi +fi # --- Restart --- -echo "Restarting $SERVICE_NAME..." +log "Restarting $SERVICE_NAME..." systemctl --user restart "$SERVICE_NAME" -echo "Status:" -systemctl --user status "$SERVICE_NAME" --no-pager +log "Status:" +systemctl --user status "$SERVICE_NAME" --no-pager || true