Skip to content

Commit 71418cc

Browse files
authored
feat: add token management to setup wizards (#146)
1 parent 108ed43 commit 71418cc

8 files changed

Lines changed: 459 additions & 32 deletions

File tree

.claude/skills/create-pr/SKILL.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,37 @@ That's it. No "Test Plan", no "Screenshots", no checklists unless truly needed.
4040

4141
## Steps
4242

43-
1. Check current state:
43+
1. **Check changed files**:
4444
```bash
4545
git diff --name-only main...HEAD
46-
git log --oneline main...HEAD
4746
```
4847

49-
2. Create PR:
48+
2. **Run code-simplifier first** (if available):
49+
50+
Run the `code-simplifier:code-simplifier` agent to simplify and clean up the code.
51+
This step modifies code, so it must run before reviews. Commit any changes it makes.
52+
53+
3. **Run validation + reviews in parallel**:
54+
55+
After code-simplifier is done, run these concurrently:
56+
- `bun run validate` (background)
57+
- Review agents based on changed files:
58+
59+
| Changed files | Agent to spawn |
60+
|---------------|----------------|
61+
| `src/agent/`, auth, user input, data handling | `security-review` |
62+
| Loops, data fetching, DB queries, heavy computation | `perf-review` |
63+
| `web/` or `mobile/` (.tsx/.jsx files) | `react-review` |
64+
65+
Spawn all applicable review agents in parallel using the Task tool.
66+
67+
4. **Fix any issues** found by validation or review agents before proceeding
68+
69+
5. **Create PR** (only after validation passes and reviews are addressed):
5070
```bash
5171
gh pr create --title "<type>: <description>" --body "$(cat <<'EOF'
5272
## Summary
53-
73+
5474
- <what>
5575
- <why>
5676
EOF

src/agent/auth.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
import { timingSafeEqual } from 'crypto';
12
import type { AgentConfig } from '../shared/types';
23
import { getTailscaleIdentity } from '../tailscale';
34

5+
function secureCompare(a: string, b: string): boolean {
6+
if (a.length !== b.length) return false;
7+
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
8+
}
9+
410
export interface AuthResult {
511
ok: boolean;
612
identity?: { type: 'token' | 'tailscale'; user?: string };
@@ -36,7 +42,7 @@ export function checkAuth(req: Request, config: AgentConfig): AuthResult {
3642
const authHeader = req.headers.get('Authorization');
3743
if (authHeader?.startsWith('Bearer ')) {
3844
const token = authHeader.slice(7);
39-
if (token === config.auth.token) {
45+
if (secureCompare(token, config.auth.token)) {
4046
return { ok: true, identity: { type: 'token' } };
4147
}
4248
}

src/agent/router.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as z from 'zod';
33
import os_module from 'os';
44
import { promises as fs } from 'fs';
55
import path from 'path';
6+
import crypto from 'crypto';
67
import type { AgentConfig } from '../shared/types';
78
import { HOST_WORKSPACE_NAME } from '../shared/client-types';
89
import { AnyWorkspaceNameSchema, UserWorkspaceNameSchema } from '../shared/workspace-name';
@@ -186,6 +187,11 @@ const TailscaleConfigSchema = z.object({
186187
hostnamePrefix: z.string().optional(),
187188
});
188189

190+
const AuthConfigSchema = z.object({
191+
hasToken: z.boolean(),
192+
tokenPreview: z.string().optional(),
193+
});
194+
189195
export interface TailscaleInfo {
190196
running: boolean;
191197
dnsName?: string;
@@ -656,6 +662,24 @@ export function createRouter(ctx: RouterContext) {
656662
};
657663
});
658664

665+
const getAuthConfig = os.output(AuthConfigSchema).handler(async () => {
666+
const config = ctx.config.get();
667+
const token = config.auth?.token;
668+
return {
669+
hasToken: !!token,
670+
tokenPreview: token ? `${token.slice(0, 10)}...${token.slice(-4)}` : undefined,
671+
};
672+
});
673+
674+
const generateAuthToken = os.output(z.object({ token: z.string() })).handler(async () => {
675+
const currentConfig = ctx.config.get();
676+
const token = `perry-${crypto.randomBytes(16).toString('hex')}`;
677+
const newConfig = { ...currentConfig, auth: { ...currentConfig.auth, token } };
678+
ctx.config.set(newConfig);
679+
await saveAgentConfig(newConfig, ctx.configDir);
680+
return { token };
681+
});
682+
659683
const GitHubRepoSchema = z.object({
660684
name: z.string(),
661685
fullName: z.string(),
@@ -1553,6 +1577,10 @@ export function createRouter(ctx: RouterContext) {
15531577
get: getTailscaleConfig,
15541578
update: updateTailscaleConfig,
15551579
},
1580+
auth: {
1581+
get: getAuthConfig,
1582+
generate: generateAuthToken,
1583+
},
15561584
},
15571585
};
15581586
}

src/cli/setup-wizard.tsx

Lines changed: 124 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import React, { useState, useEffect } from 'react';
22
import { render, Box, Text, useInput, useApp } from 'ink';
33
import TextInput from 'ink-text-input';
4+
import crypto from 'crypto';
45
import { loadAgentConfig, saveAgentConfig, getConfigDir, ensureConfigDir } from '../config/loader';
56
import { discoverSSHKeys } from '../ssh';
67
import type { SSHKeyInfo } from '../shared/client-types';
78

8-
type Step = 'welcome' | 'github' | 'ssh' | 'tailscale' | 'complete';
9+
type Step = 'welcome' | 'auth' | 'github' | 'ssh' | 'tailscale' | 'complete';
910

10-
const STEPS: Step[] = ['welcome', 'github', 'ssh', 'tailscale', 'complete'];
11+
const STEPS: Step[] = ['welcome', 'auth', 'github', 'ssh', 'tailscale', 'complete'];
1112

1213
interface WizardState {
14+
authToken: string;
15+
authTokenGenerated: boolean;
1316
githubToken: string;
1417
selectedSSHKeys: string[];
1518
tailscaleAuthKey: string;
@@ -31,6 +34,7 @@ function WelcomeStep({ onNext }: { onNext: () => void }) {
3134
</Box>
3235
<Text>This wizard will help you configure:</Text>
3336
<Box marginLeft={2} flexDirection="column">
37+
<Text>• API security (auth token)</Text>
3438
<Text>• Git access (GitHub token)</Text>
3539
<Text>• SSH keys for workspaces</Text>
3640
<Text>• Tailscale networking</Text>
@@ -69,7 +73,7 @@ function TokenInputStep({
6973
} else if (key.escape) {
7074
onBack();
7175
} else if (input === 'v' && key.ctrl) {
72-
setShowValue((s: boolean) => !s);
76+
setShowValue((s) => !s);
7377
} else if (input === 's' && optional) {
7478
onNext();
7579
}
@@ -100,6 +104,81 @@ function TokenInputStep({
100104
);
101105
}
102106

107+
function AuthStep({
108+
token,
109+
isNew,
110+
onGenerate,
111+
onNext,
112+
onBack,
113+
}: {
114+
token: string;
115+
isNew: boolean;
116+
onGenerate: () => void;
117+
onNext: () => void;
118+
onBack: () => void;
119+
}) {
120+
const [showToken, setShowToken] = useState(false);
121+
122+
useInput((input, key) => {
123+
if (key.return) {
124+
onNext();
125+
} else if (key.escape) {
126+
onBack();
127+
} else if (input === 'g' && !token) {
128+
onGenerate();
129+
} else if (input === 'v' && key.ctrl) {
130+
setShowToken((s) => !s);
131+
}
132+
});
133+
134+
const maskedToken = token ? `${token.slice(0, 10)}...${token.slice(-4)}` : '';
135+
136+
return (
137+
<Box flexDirection="column" gap={1}>
138+
<Text bold>API Security</Text>
139+
<Text color="gray">
140+
Secure your Perry agent with token authentication. Clients will need this token to connect.
141+
</Text>
142+
143+
<Box marginTop={1} flexDirection="column">
144+
{token ? (
145+
<>
146+
<Box>
147+
<Text color="green"></Text>
148+
<Text>Auth token {isNew ? 'generated' : 'configured'}</Text>
149+
</Box>
150+
<Box marginTop={1}>
151+
<Text>Token: </Text>
152+
<Text color="cyan">{showToken ? token : maskedToken}</Text>
153+
</Box>
154+
<Box marginTop={1} flexDirection="column">
155+
<Text color="yellow">Save this token! You'll need it to configure clients:</Text>
156+
<Box marginLeft={2}>
157+
<Text color="gray">CLI: perry config token {showToken ? token : '<token>'}</Text>
158+
</Box>
159+
<Box marginLeft={2}>
160+
<Text color="gray">Web: Enter when prompted on first visit</Text>
161+
</Box>
162+
</Box>
163+
</>
164+
) : (
165+
<>
166+
<Text color="yellow">No auth token configured.</Text>
167+
<Text>Without a token, anyone with network access can control your agent.</Text>
168+
</>
169+
)}
170+
</Box>
171+
172+
<Box marginTop={1}>
173+
<Text color="gray">
174+
{token ? 'Ctrl+V to show/hide token, ' : 'G to generate token, '}
175+
Enter to continue, Esc to go back
176+
</Text>
177+
</Box>
178+
</Box>
179+
);
180+
}
181+
103182
function SSHKeySelectStep({
104183
keys,
105184
selected,
@@ -118,9 +197,9 @@ function SSHKeySelectStep({
118197

119198
useInput((input, key) => {
120199
if (key.upArrow) {
121-
setHighlighted((h: number) => Math.max(0, h - 1));
200+
setHighlighted((h) => Math.max(0, h - 1));
122201
} else if (key.downArrow) {
123-
setHighlighted((h: number) => Math.min(privateKeys.length - 1, h + 1));
202+
setHighlighted((h) => Math.min(privateKeys.length - 1, h + 1));
124203
} else if (input === ' ' && privateKeys.length > 0) {
125204
onToggle(privateKeys[highlighted].path);
126205
} else if (key.return) {
@@ -175,6 +254,7 @@ function CompleteStep({ state, onFinish }: { state: WizardState; onFinish: () =>
175254
});
176255

177256
const configured: string[] = [];
257+
if (state.authToken) configured.push('Auth token');
178258
if (state.githubToken) configured.push('GitHub');
179259
if (state.selectedSSHKeys.length > 0)
180260
configured.push(`${state.selectedSSHKeys.length} SSH key(s)`);
@@ -197,6 +277,14 @@ function CompleteStep({ state, onFinish }: { state: WizardState; onFinish: () =>
197277
) : (
198278
<Text color="yellow">No configuration added. You can always configure later.</Text>
199279
)}
280+
{state.authToken && (
281+
<Box marginTop={1} flexDirection="column">
282+
<Text color="yellow">Remember to configure your clients with the auth token:</Text>
283+
<Box marginLeft={2}>
284+
<Text color="gray">perry config token {'<token>'}</Text>
285+
</Box>
286+
</Box>
287+
)}
200288
<Box marginTop={1}>
201289
<Text>Start the agent with: </Text>
202290
<Text color="cyan">perry agent run</Text>
@@ -217,6 +305,8 @@ function SetupWizard() {
217305
const [step, setStep] = useState<Step>('welcome');
218306
const [sshKeys, setSSHKeys] = useState<SSHKeyInfo[]>([]);
219307
const [state, setState] = useState<WizardState>({
308+
authToken: '',
309+
authTokenGenerated: false,
220310
githubToken: '',
221311
selectedSSHKeys: [],
222312
tailscaleAuthKey: '',
@@ -234,8 +324,10 @@ function SetupWizard() {
234324
const configDir = getConfigDir();
235325
await ensureConfigDir(configDir);
236326
const config = await loadAgentConfig(configDir);
237-
setState((s: WizardState) => ({
327+
setState((s) => ({
238328
...s,
329+
authToken: config.auth?.token || '',
330+
authTokenGenerated: false,
239331
githubToken: config.agents?.github?.token || '',
240332
selectedSSHKeys: config.ssh?.global.copy || [],
241333
tailscaleAuthKey: config.tailscale?.authKey || '',
@@ -259,21 +351,34 @@ function SetupWizard() {
259351
};
260352

261353
const toggleSSHKey = (path: string) => {
262-
setState((s: WizardState) => ({
354+
setState((s) => ({
263355
...s,
264356
selectedSSHKeys: s.selectedSSHKeys.includes(path)
265-
? s.selectedSSHKeys.filter((k: string) => k !== path)
357+
? s.selectedSSHKeys.filter((k) => k !== path)
266358
: [...s.selectedSSHKeys, path],
267359
}));
268360
};
269361

362+
const generateAuthToken = () => {
363+
const token = `perry-${crypto.randomBytes(16).toString('hex')}`;
364+
setState((s) => ({
365+
...s,
366+
authToken: token,
367+
authTokenGenerated: true,
368+
}));
369+
};
370+
270371
const saveAndFinish = async () => {
271372
setSaving(true);
272373
try {
273374
const configDir = getConfigDir();
274375
await ensureConfigDir(configDir);
275376
const config = await loadAgentConfig(configDir);
276377

378+
if (state.authTokenGenerated && state.authToken) {
379+
config.auth = { ...config.auth, token: state.authToken };
380+
}
381+
277382
if (state.githubToken) {
278383
config.agents = {
279384
...config.agents,
@@ -325,13 +430,22 @@ function SetupWizard() {
325430
) : (
326431
<>
327432
{step === 'welcome' && <WelcomeStep onNext={nextStep} />}
433+
{step === 'auth' && (
434+
<AuthStep
435+
token={state.authToken}
436+
isNew={state.authTokenGenerated}
437+
onGenerate={generateAuthToken}
438+
onNext={nextStep}
439+
onBack={prevStep}
440+
/>
441+
)}
328442
{step === 'github' && (
329443
<TokenInputStep
330444
title="GitHub Personal Access Token"
331445
placeholder="ghp_... or github_pat_..."
332446
helpText="Create at https://github.com/settings/personal-access-tokens/new"
333447
value={state.githubToken}
334-
onChange={(v: string) => setState((s: WizardState) => ({ ...s, githubToken: v }))}
448+
onChange={(v) => setState((s) => ({ ...s, githubToken: v }))}
335449
onNext={nextStep}
336450
onBack={prevStep}
337451
optional
@@ -352,9 +466,7 @@ function SetupWizard() {
352466
placeholder="tskey-auth-..."
353467
helpText="Generate at https://login.tailscale.com/admin/settings/keys (Reusable: Yes, Ephemeral: No)"
354468
value={state.tailscaleAuthKey}
355-
onChange={(v: string) =>
356-
setState((s: WizardState) => ({ ...s, tailscaleAuthKey: v }))
357-
}
469+
onChange={(v) => setState((s) => ({ ...s, tailscaleAuthKey: v }))}
358470
onNext={nextStep}
359471
onBack={prevStep}
360472
optional

0 commit comments

Comments
 (0)