2ded310472
- Add a dedicated plan-approval bottom sheet (interactive-prompt) driven by ExitPlanMode's now-4-option layout (Claude Code v2.1.177), with approve/YOLO/reject/feedback wired through respondPlan(requestId). - PermissionManager: stop auto-expiring AskUserQuestion/plan prompts and stop releasing pending prompts on client disconnect — they reflect real CLI state and must survive reconnects. - Detect server-rejected tokens (401/403 or failed WS handshake) and bounce back to the login screen via a new AUTH_EXPIRED_EVENT. - Add `/cert` endpoint + Settings entry so phones can download/trust the self-signed HTTPS cert directly; fix cert IP detection on non-macOS hosts. - Add `clawtap reset-login` command and `/api/auth/reset-attempts` endpoint to clear login rate-limit lockouts. - Codex adapter: only forward recognized keystrokes/option indices to tmux, never the synthetic "deny" dismissal signal. - PWA: actively poll for service-worker updates (iOS standalone apps don't reliably check on navigation). - update-service.sh: install tmux if missing, prompt to create the env file instead of failing, add --skip-firewall, and improve logging.
174 lines
5.9 KiB
TypeScript
174 lines
5.9 KiB
TypeScript
/**
|
|
* PermissionManager — manages pending permission and question requests.
|
|
*
|
|
* Extracted from TmuxAdapter to allow reuse across adapters.
|
|
* Handles timeouts, session-scoped indexing, and bulk operations.
|
|
*/
|
|
|
|
export interface PendingPermission {
|
|
sessionId: string;
|
|
requestId: string;
|
|
toolName: string;
|
|
input: Record<string, unknown>;
|
|
timer: ReturnType<typeof setTimeout>;
|
|
}
|
|
|
|
export interface PendingQuestion {
|
|
sessionId: string;
|
|
requestId: string;
|
|
originalInput: Record<string, unknown>;
|
|
/** 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 {
|
|
private pendingPermissions = new Map<string, PendingPermission>();
|
|
private pendingQuestions = new Map<string, PendingQuestion>();
|
|
/** sessionId -> Set<requestId> for fast session-scoped lookups */
|
|
private sessionPendingIds = new Map<string, Set<string>>();
|
|
private timeoutMs: number;
|
|
|
|
constructor(timeoutMs = 120_000) {
|
|
this.timeoutMs = timeoutMs;
|
|
}
|
|
|
|
// === Permissions ===
|
|
|
|
addPermission(requestId: string, sessionId: string, data: { toolName: string; input: Record<string, unknown> }): void {
|
|
const timer = setTimeout(() => {
|
|
this.pendingPermissions.delete(requestId);
|
|
this._removePendingId(sessionId, requestId);
|
|
}, this.timeoutMs);
|
|
|
|
this.pendingPermissions.set(requestId, {
|
|
sessionId,
|
|
requestId,
|
|
toolName: data.toolName,
|
|
input: data.input,
|
|
timer,
|
|
});
|
|
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;
|
|
clearTimeout(pending.timer);
|
|
this.pendingPermissions.delete(requestId);
|
|
this._removePendingId(pending.sessionId, requestId);
|
|
return pending;
|
|
}
|
|
|
|
// === Questions ===
|
|
|
|
addQuestion(requestId: string, sessionId: string, data: { originalInput: Record<string, unknown>; 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,
|
|
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;
|
|
this.pendingQuestions.delete(requestId);
|
|
this._removePendingId(pending.sessionId, requestId);
|
|
return pending;
|
|
}
|
|
|
|
// === Session-scoped Operations ===
|
|
|
|
/** Get all pending permissions for a session (for reconnect replay). */
|
|
getPendingForSession(sessionId: string): PendingPermission[] {
|
|
const ids = this.sessionPendingIds.get(sessionId);
|
|
if (!ids) return [];
|
|
const result: PendingPermission[] = [];
|
|
for (const reqId of ids) {
|
|
const perm = this.pendingPermissions.get(reqId);
|
|
if (perm) result.push(perm);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Get all pending questions for a session (for reconnect replay). */
|
|
getQuestionsForSession(sessionId: string): PendingQuestion[] {
|
|
const ids = this.sessionPendingIds.get(sessionId);
|
|
if (!ids) return [];
|
|
const result: PendingQuestion[] = [];
|
|
for (const reqId of ids) {
|
|
const q = this.pendingQuestions.get(reqId);
|
|
if (q) result.push(q);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Clear all pending requests for a session (e.g., when all clients disconnect or turn ends). */
|
|
dismissAll(sessionId: string): void {
|
|
const ids = this.sessionPendingIds.get(sessionId);
|
|
if (!ids) return;
|
|
for (const reqId of ids) {
|
|
const perm = this.pendingPermissions.get(reqId);
|
|
if (perm) { clearTimeout(perm.timer); this.pendingPermissions.delete(reqId); }
|
|
const q = this.pendingQuestions.get(reqId);
|
|
if (q) { this.pendingQuestions.delete(reqId); }
|
|
}
|
|
this.sessionPendingIds.delete(sessionId);
|
|
}
|
|
|
|
/**
|
|
* Resolve all pending permissions for a session as a given behavior.
|
|
* Returns the list of resolved permission requestIds (caller handles the actual response).
|
|
*/
|
|
resolveAllAs(sessionId: string, _behavior: string): string[] {
|
|
const ids = this.sessionPendingIds.get(sessionId);
|
|
if (!ids) return [];
|
|
const resolved: string[] = [];
|
|
for (const reqId of ids) {
|
|
const perm = this.pendingPermissions.get(reqId);
|
|
if (perm) {
|
|
clearTimeout(perm.timer);
|
|
this.pendingPermissions.delete(reqId);
|
|
resolved.push(reqId);
|
|
}
|
|
const q = this.pendingQuestions.get(reqId);
|
|
if (q) { this.pendingQuestions.delete(reqId); }
|
|
}
|
|
this.sessionPendingIds.delete(sessionId);
|
|
return resolved;
|
|
}
|
|
|
|
// === Internal ===
|
|
|
|
private _trackPendingId(sessionId: string, requestId: string): void {
|
|
let ids = this.sessionPendingIds.get(sessionId);
|
|
if (!ids) { ids = new Set(); this.sessionPendingIds.set(sessionId, ids); }
|
|
ids.add(requestId);
|
|
}
|
|
|
|
private _removePendingId(sessionId: string, requestId: string): void {
|
|
const ids = this.sessionPendingIds.get(sessionId);
|
|
if (ids) { ids.delete(requestId); if (ids.size === 0) this.sessionPendingIds.delete(sessionId); }
|
|
}
|
|
}
|