Skip to content

Commit 02adb75

Browse files
Amos N.claude
andcommitted
Replace auto-approve dialog with phone-side password verification
The original CSO #4 gate (osascript Allow/Deny dialog) required physical presence at the laptop to enable auto-approve, which defeats the entire premise of remote phone control. Replace it with a phone-supplied macOS password, verified non-interactively via `dscl . -authonly` (same backend as sudo). On first success the daemon mints a per-phone random token and returns it to the phone; the phone caches it in secure storage and reuses it on every subsequent auto-approve session-open. No more prompts, no laptop interaction required. Token storage: ~/.loopsy/auto-approve.json (0600), keyed by phone_id, sha256(token) only — raw token never persisted on the daemon. `loopsy phone revoke` now also wipes the local entry so a stolen-then-revoked phone can't keep using its cached token. Mobile changes: new password modal in pair/terminal flow, auto-approve toggle that surfaces wrong-password / no-credentials states from the new auto-approve-denied control frame. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bee3745 commit 02adb75

9 files changed

Lines changed: 1005 additions & 219 deletions

File tree

apps/mobile/lib/screens/home_screen.dart

Lines changed: 149 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import '../models/session_meta.dart';
99
import '../services/relay_client.dart';
1010
import '../services/storage.dart';
1111
import '../theme.dart';
12+
import '../widgets/loopsy_modal.dart';
1213

1314
class HomeScreen extends StatefulWidget {
1415
const HomeScreen({super.key});
@@ -73,118 +74,143 @@ class _HomeScreenState extends State<HomeScreen> {
7374
}
7475

7576
Future<bool?> _promptAutoApprove(String agent, {required bool initial}) async {
76-
final flagDescription = switch (agent) {
77+
final flag = switch (agent) {
7778
'claude' => '--dangerously-skip-permissions',
7879
'gemini' => '-y',
7980
'codex' => '--full-auto',
8081
_ => '',
8182
};
82-
return showDialog<bool>(
83+
return showLoopsyDialog<bool>(
8384
context: context,
84-
builder: (ctx) => AlertDialog(
85-
backgroundColor: LoopsyColors.surface,
86-
title: const Text('Auto-approve actions?'),
87-
content: Column(
88-
mainAxisSize: MainAxisSize.min,
89-
crossAxisAlignment: CrossAxisAlignment.start,
90-
children: [
91-
Text(
92-
'Skip the agent\'s confirmation prompts so you don\'t have to keep tapping "yes" on the phone.',
93-
style: const TextStyle(color: LoopsyColors.muted, fontSize: 13),
85+
icon: HugeIcons.strokeRoundedFlash,
86+
title: 'Auto-approve actions?',
87+
subtitle:
88+
'Skip the agent\'s confirmation prompts so you don\'t have to keep tapping "yes". '
89+
'The first auto-approve session will ask for your macOS password once — after that, future sessions skip it.',
90+
body: Column(
91+
crossAxisAlignment: CrossAxisAlignment.start,
92+
mainAxisSize: MainAxisSize.min,
93+
children: [
94+
Container(
95+
width: double.infinity,
96+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
97+
decoration: BoxDecoration(
98+
color: LoopsyColors.surfaceAlt,
99+
borderRadius: BorderRadius.circular(8),
100+
border: Border.all(color: LoopsyColors.border),
94101
),
95-
const SizedBox(height: 10),
96-
Container(
97-
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
98-
decoration: BoxDecoration(
99-
color: LoopsyColors.surfaceAlt,
100-
borderRadius: BorderRadius.circular(6),
101-
),
102-
child: Text(
103-
'$agent $flagDescription',
104-
style: const TextStyle(fontFamily: 'JetBrainsMono', fontSize: 12),
102+
child: Text(
103+
'$agent $flag',
104+
style: const TextStyle(
105+
fontFamily: 'JetBrainsMono',
106+
fontSize: 12.5,
107+
color: LoopsyColors.fg,
105108
),
106109
),
107-
const SizedBox(height: 10),
108-
Text(
109-
'The agent will execute file edits, shell commands, etc. without asking. Make sure you trust the prompt.',
110-
style: const TextStyle(color: LoopsyColors.warn, fontSize: 12),
111-
),
112-
],
113-
),
114-
actions: [
115-
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
116-
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Stay safe')),
117-
ElevatedButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Auto-approve')),
110+
),
111+
const SizedBox(height: 12),
112+
Row(
113+
crossAxisAlignment: CrossAxisAlignment.start,
114+
children: [
115+
const HugeIcon(icon: HugeIcons.strokeRoundedAlert02, color: LoopsyColors.warn, size: 16),
116+
const SizedBox(width: 8),
117+
Expanded(
118+
child: Text(
119+
'The agent will run file edits and shell commands without asking. Trust the prompt.',
120+
style: const TextStyle(color: LoopsyColors.warn, fontSize: 12.5, height: 1.4),
121+
),
122+
),
123+
],
124+
),
118125
],
119126
),
127+
actions: [
128+
LoopsyModalAction.text('Cancel', () => Navigator.pop(context)),
129+
LoopsyModalAction.outlined('Stay safe', () => Navigator.pop(context, false)),
130+
LoopsyModalAction.primary('Auto-approve', () => Navigator.pop(context, true)),
131+
],
120132
);
121133
}
122134

123135
Future<String?> _pickAgent() async {
124-
return showModalBottomSheet<String>(
136+
return showLoopsySheet<String>(
125137
context: context,
126-
backgroundColor: LoopsyColors.surface,
127-
shape: const RoundedRectangleBorder(
128-
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
129-
),
130-
builder: (ctx) => SafeArea(
131-
child: Column(
132-
mainAxisSize: MainAxisSize.min,
133-
children: [
134-
const Padding(
135-
padding: EdgeInsets.fromLTRB(20, 16, 20, 8),
136-
child: Row(
137-
children: [
138-
HugeIcon(icon: HugeIcons.strokeRoundedAddSquare, color: LoopsyColors.accent, size: 22),
139-
SizedBox(width: 10),
140-
Text('Start a session', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
141-
],
142-
),
143-
),
144-
..._agentTile(ctx, 'shell', HugeIcons.strokeRoundedCommandLine, 'Bash on your laptop'),
145-
..._agentTile(ctx, 'claude', HugeIcons.strokeRoundedAiChat02, 'Claude Code'),
146-
..._agentTile(ctx, 'gemini', HugeIcons.strokeRoundedAiBrain02, 'Gemini CLI'),
147-
..._agentTile(ctx, 'codex', HugeIcons.strokeRoundedSourceCode, 'OpenAI Codex CLI'),
148-
const SizedBox(height: 12),
149-
],
150-
),
138+
icon: HugeIcons.strokeRoundedAddSquare,
139+
title: 'Start a session',
140+
subtitle: 'Pick an agent. The session lives on your laptop and you can switch back to it anytime.',
141+
body: Column(
142+
mainAxisSize: MainAxisSize.min,
143+
children: [
144+
LoopsyMenuTile(
145+
icon: HugeIcons.strokeRoundedCommandLine,
146+
title: 'shell',
147+
subtitle: 'Bash on your laptop',
148+
onTap: () => Navigator.pop(context, 'shell'),
149+
),
150+
LoopsyMenuTile(
151+
icon: HugeIcons.strokeRoundedAiChat02,
152+
iconColor: LoopsyColors.accent,
153+
title: 'claude',
154+
subtitle: 'Claude Code',
155+
onTap: () => Navigator.pop(context, 'claude'),
156+
),
157+
LoopsyMenuTile(
158+
icon: HugeIcons.strokeRoundedAiBrain02,
159+
iconColor: LoopsyColors.accent,
160+
title: 'gemini',
161+
subtitle: 'Gemini CLI',
162+
onTap: () => Navigator.pop(context, 'gemini'),
163+
),
164+
LoopsyMenuTile(
165+
icon: HugeIcons.strokeRoundedSourceCode,
166+
iconColor: LoopsyColors.accent,
167+
title: 'codex',
168+
subtitle: 'OpenAI Codex CLI',
169+
onTap: () => Navigator.pop(context, 'codex'),
170+
),
171+
],
151172
),
152173
);
153174
}
154175

155176
Future<String?> _promptName({required String initial, required String title}) async {
156177
final ctl = TextEditingController(text: initial);
157-
return showDialog<String>(
178+
return showLoopsyDialog<String>(
158179
context: context,
159-
builder: (ctx) => AlertDialog(
160-
backgroundColor: LoopsyColors.surface,
161-
title: Text(title),
162-
content: TextField(
163-
controller: ctl,
164-
autofocus: true,
165-
decoration: const InputDecoration(hintText: 'e.g. fix logging bug'),
166-
textCapitalization: TextCapitalization.sentences,
167-
),
168-
actions: [
169-
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Skip')),
170-
ElevatedButton(
171-
onPressed: () => Navigator.pop(ctx, ctl.text),
172-
child: const Text('Save'),
180+
icon: HugeIcons.strokeRoundedEdit01,
181+
title: title,
182+
subtitle: 'Pick a name you\'ll recognize on the home list.',
183+
body: TextField(
184+
controller: ctl,
185+
autofocus: true,
186+
textCapitalization: TextCapitalization.sentences,
187+
decoration: InputDecoration(
188+
hintText: 'e.g. fix logging bug',
189+
hintStyle: const TextStyle(color: LoopsyColors.muted),
190+
filled: true,
191+
fillColor: LoopsyColors.surfaceAlt,
192+
border: OutlineInputBorder(
193+
borderRadius: BorderRadius.circular(10),
194+
borderSide: const BorderSide(color: LoopsyColors.border),
173195
),
174-
],
196+
enabledBorder: OutlineInputBorder(
197+
borderRadius: BorderRadius.circular(10),
198+
borderSide: const BorderSide(color: LoopsyColors.border),
199+
),
200+
focusedBorder: OutlineInputBorder(
201+
borderRadius: BorderRadius.circular(10),
202+
borderSide: const BorderSide(color: LoopsyColors.accent),
203+
),
204+
),
205+
style: const TextStyle(color: LoopsyColors.fg),
175206
),
207+
actions: [
208+
LoopsyModalAction.text('Skip', () => Navigator.pop(context)),
209+
LoopsyModalAction.primary('Save', () => Navigator.pop(context, ctl.text)),
210+
],
176211
);
177212
}
178213

179-
List<Widget> _agentTile(BuildContext ctx, String agent, IconData icon, String subtitle) => [
180-
ListTile(
181-
leading: HugeIcon(icon: icon, color: LoopsyColors.fg),
182-
title: Text(agent, style: const TextStyle(fontFamily: 'JetBrainsMono')),
183-
subtitle: Text(subtitle, style: const TextStyle(color: LoopsyColors.muted, fontSize: 12)),
184-
onTap: () => Navigator.pop(ctx, agent),
185-
),
186-
];
187-
188214
Future<void> _resumeSession(SessionMeta s) async {
189215
if (!mounted) return;
190216
final res = await context.push(
@@ -206,34 +232,35 @@ class _HomeScreenState extends State<HomeScreen> {
206232
}
207233

208234
Future<void> _showSessionMenu(SessionMeta s) async {
209-
final action = await showModalBottomSheet<String>(
235+
final action = await showLoopsySheet<String>(
210236
context: context,
211-
backgroundColor: LoopsyColors.surface,
212-
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(18))),
213-
builder: (ctx) => SafeArea(
214-
child: Column(
215-
mainAxisSize: MainAxisSize.min,
216-
children: [
217-
ListTile(
218-
leading: const HugeIcon(icon: HugeIcons.strokeRoundedEdit01, color: LoopsyColors.fg),
219-
title: const Text('Rename'),
220-
onTap: () => Navigator.pop(ctx, 'rename'),
221-
),
222-
ListTile(
223-
leading: const HugeIcon(icon: HugeIcons.strokeRoundedRemove01, color: LoopsyColors.warn),
224-
title: const Text('Remove from list'),
225-
subtitle: const Text('Keeps the laptop session running.'),
226-
onTap: () => Navigator.pop(ctx, 'remove'),
227-
),
228-
ListTile(
229-
leading: const HugeIcon(icon: HugeIcons.strokeRoundedDelete02, color: LoopsyColors.bad),
230-
title: const Text('Delete'),
231-
subtitle: const Text('Stops the laptop session, removes from list.'),
232-
onTap: () => Navigator.pop(ctx, 'delete'),
233-
),
234-
const SizedBox(height: 8),
235-
],
236-
),
237+
icon: HugeIcons.strokeRoundedMoreHorizontal,
238+
title: s.name ?? s.summary ?? '${s.agent} session',
239+
subtitle: 'session ${s.id.substring(0, 6)} · ${s.agent}',
240+
body: Column(
241+
mainAxisSize: MainAxisSize.min,
242+
children: [
243+
LoopsyMenuTile(
244+
icon: HugeIcons.strokeRoundedEdit01,
245+
title: 'Rename',
246+
onTap: () => Navigator.pop(context, 'rename'),
247+
),
248+
LoopsyMenuTile(
249+
icon: HugeIcons.strokeRoundedRemove01,
250+
iconColor: LoopsyColors.warn,
251+
title: 'Remove from list',
252+
subtitle: 'Keeps the laptop session running.',
253+
onTap: () => Navigator.pop(context, 'remove'),
254+
),
255+
LoopsyMenuTile(
256+
icon: HugeIcons.strokeRoundedDelete02,
257+
iconColor: LoopsyColors.bad,
258+
titleColor: LoopsyColors.bad,
259+
title: 'Delete',
260+
subtitle: 'Stops the laptop session and removes it.',
261+
onTap: () => Navigator.pop(context, 'delete'),
262+
),
263+
],
237264
),
238265
);
239266
if (action == 'rename') return _renameSession(s);
@@ -265,20 +292,18 @@ class _HomeScreenState extends State<HomeScreen> {
265292
}
266293

267294
Future<void> _resetPairing() async {
268-
final ok = await showDialog<bool>(
295+
final ok = await showLoopsyDialog<bool>(
269296
context: context,
270-
builder: (ctx) => AlertDialog(
271-
backgroundColor: LoopsyColors.surface,
272-
title: const Text('Forget pairing?'),
273-
content: const Text(
274-
'You’ll need to re-scan a pair QR from your laptop to reconnect.',
275-
style: TextStyle(color: LoopsyColors.muted),
276-
),
277-
actions: [
278-
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
279-
ElevatedButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Forget')),
280-
],
281-
),
297+
icon: HugeIcons.strokeRoundedDelete02,
298+
iconColor: LoopsyColors.bad,
299+
title: 'Forget pairing?',
300+
subtitle:
301+
'You\'ll need to re-scan a pair QR from your laptop to reconnect. '
302+
'Your auto-approve token will be wiped too.',
303+
actions: [
304+
LoopsyModalAction.text('Cancel', () => Navigator.pop(context, false)),
305+
LoopsyModalAction.danger('Forget', () => Navigator.pop(context, true)),
306+
],
282307
);
283308
if (ok != true) return;
284309
// CSO #8: also revoke on the relay so the phone record stops existing

0 commit comments

Comments
 (0)