45ca7519f2cd

let koh remote replace or repair the setting on a terminal, refuse credentials others can read, and document the hand-written config files (3.0.58)

time
2026-09-26 02:02 UTC · 2 weeks ago
machine
koh2
agent
claude-opus-5-5
session
remote setup
parent
6287b5567dc3
5 files VERSION+1 −1src/cli/cmd_remote.v+18 −3src/machine/settings.v+7 −1tests/cli_test.v+43 −0v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.571+3.0.58

view file

src/cli/cmd_remote.v+18 −3
@@ -43,7 +43,14 @@4343 			}4444 			return setup_wizard()4545 		}46-		settings := machine.load_remote() or { return refuse(args.json(), err.msg()) }46+		settings := machine.load_remote() or {47+			// Otherwise the message to run "koh remote" again would loop.48+			if !args.json() && interactive() {49+				warn(err.msg())50+				return setup_wizard()51+			}52+			return refuse(args.json(), err.msg())53+		}4754 		if args.json() {4855 			emit_json(json2.encode(RemoteJson{4956 				configured: true@@ -53,8 +60,16 @@5360 			return 05461 		}5562 		ok('remote: ${describe(settings)}')56-		note('run "koh remote folder <path>" or "koh remote s3 <endpoint> <bucket> <region>" to replace it')57-		return 063+		if !interactive() {64+			note('run "koh remote folder <path>" or "koh remote s3 <endpoint> <bucket> <region>" to replace it')65+			return 066+		}67+		print('  replace it? [y/N] ')68+		if os.get_line().trim_right('\r') != 'y' {69+			return 070+		}71+		// The old remote stays until the new one passes its test.72+		return setup_wizard()5873 	}5974 	// A non-interactive form, still tested before it is saved.6075 	return set_remote(args)

view file

src/machine/settings.v+7 −1
@@ -121,8 +121,14 @@121121 }122122 123123 pub fn load_credentials() !Credentials {124-	text := os.read_file(config_path(credentials_file)) or {124+	path := config_path(credentials_file)125+	text := os.read_file(path) or {125126 		return error('this machine has no S3 keys yet — run "koh remote"')127+	}128+	// Like ssh with a private key: a hand-written file others can read is129+	// refused rather than used.130+	if os.stat(path)!.mode & 0o077 != 0 {131+		return error('${path} can be read by others — run "chmod 600 ${path}"')126132 	}127133 	values := parse_config(text)128134 	credentials := Credentials{

view file

tests/cli_test.v+43 −0
@@ -657,6 +657,49 @@657657 	assert run_home(dir, home, 'remote', '--json').out.contains('"configured":false')658658 }659659 660+// The files under ~/.config/koh can be written by hand instead of by661+// `koh remote`, and are then read the same way.662+fn test_remote_written_by_hand() {663+	dir := temp_repo('remote-by-hand')664+	home := temp_repo('remote-by-hand-home')665+	defer {666+		os.rmdir_all(dir) or {}667+		os.rmdir_all(home) or {}668+	}669+	config := os.join_path(home, '.config', 'koh')670+	os.mkdir_all(config) or { panic(err) }671+	target := os.join_path(home, 'remote-store')672+	os.mkdir_all(target) or { panic(err) }673+	os.write_file(os.join_path(config, 'remote'), 'kind = folder\npath = ${target}\n') or {674+		panic(err)675+	}676+	assert run_home(dir, home, 'init').code == 0677+	os.write_file(os.join_path(dir, 'a.txt'), 'a\n') or { panic(err) }678+	assert run_home(dir, home, 'save', 'first').code == 0679+	offered := run_home(dir, home, 'offer', '--json')680+	assert offered.code == 0681+	assert offered.out.contains('"head"')682+683+	// Keys anyone else can read are refused before they are ever sent.684+	os.write_file(os.join_path(config, 'remote'), 'kind=s3\nendpoint=http://127.0.0.1:1\nbucket=b\nregion=r\n') or {685+		panic(err)686+	}687+	credentials := os.join_path(config, 'credentials')688+	os.write_file(credentials, 'access_key=AK\nsecret_key=SK\n') or { panic(err) }689+	os.chmod(credentials, 0o644) or { panic(err) }690+	loose := run_home(dir, home, 'offer')691+	assert loose.code == 1692+	assert loose.err.contains('chmod 600')693+694+	// A setting koh cannot read is replaced by setting the remote again.695+	os.write_file(os.join_path(config, 'remote'), 'kind=ftp\n') or { panic(err) }696+	unreadable := run_home(dir, home, 'offer')697+	assert unreadable.code == 1698+	assert unreadable.err.contains('not readable')699+	assert run_home(dir, home, 'remote', 'folder', target).code == 0700+	assert run_home(dir, home, 'offer').code == 0701+}702+660703 // A clone belongs to the repo it was stolen from, whatever its folder is661704 // called later: renaming it must not start a second repo on the remote.662705 fn test_a_renamed_clone_keeps_offering_to_its_repo() {

view file

v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 	name: 'koh3'3-	version: '3.0.57'3+	version: '3.0.58'44 	description: 'One SQLite file per repo, one line of history.'55 	license: 'MIT'66 }

view file